From ac390faff218ee8da325ebcb150226c4cba3567c Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Tue, 27 Jan 2026 16:17:34 -0600 Subject: [PATCH 01/17] chore(ci): add --scope to vercel deploy and alias commands (#2847) --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9039e24ad..2e8edf081 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -70,7 +70,7 @@ jobs: DEPLOY_ARGS+=(--env BIGCOMMERCE_ACCESS_TOKEN="$BIGCOMMERCE_ACCESS_TOKEN") fi - DEPLOYMENT_URL=$(vercel deploy "${DEPLOY_ARGS[@]}") + DEPLOYMENT_URL=$(vercel deploy --scope="${{ vars.VERCEL_TEAM_SLUG }}" "${DEPLOY_ARGS[@]}") echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT - name: Set Vercel Domain Alias @@ -78,4 +78,4 @@ jobs: env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - vercel alias ${{ steps.deploy.outputs.deployment_url }} $DOMAIN --token="$VERCEL_TOKEN" + vercel alias ${{ steps.deploy.outputs.deployment_url }} $DOMAIN --scope="${{ vars.VERCEL_TEAM_SLUG }}" --token="$VERCEL_TOKEN" From 925934849f3ebc9f352380f8318911278b02c183 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 27 Jan 2026 18:01:26 -0600 Subject: [PATCH 02/17] chore: update contributing file with some notes from latest release (#2841) * chore: update contributing file with some notes from latest release * chore: add info about pushing tags * fix: remove duplicate line --- CONTRIBUTING.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d69b24dfd..7d91bd6bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,7 @@ In order to complete the following steps, you will need to have met the followin > > - The `name` field in `core/package.json` should remain `@bigcommerce/catalyst-makeswift` > - The `version` field in `core/package.json` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was +> - The latest release in `core/CHANGELOG.md` should remain whatever the latest published `@bigcommerce/catalyst-makeswift` version was 4. After resolving any merge conflicts, open a new PR in GitHub to merge your `sync-integrations-makeswift` into `integrations/makeswift`. This PR should be code reviewed and approved before the next steps. @@ -104,14 +105,35 @@ This ensures `integrations/makeswift` remains a faithful mirror of `canary` whil - 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: + + ``` + --- + "@bigcommerce/catalyst-makeswift": patch + --- + + Pulls in changes from the `@bigcommerce/catalyst-core@1.4.1` patch. + ``` + - 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 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. +5. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. If needed, update `latest` tags in GitHub manually. + +- 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 + ``` + ### Additional Notes -- **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. If needed, update `latest` tags in GitHub manually. - **Release cadence:** Teams typically review on Wednesdays whether to cut a release, but you may cut releases more frequently as needed. ## Other Ways to Contribute From 207ee86f2f43048074f02d22393f9f01c8ca6adc Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Wed, 28 Jan 2026 10:55:04 -0600 Subject: [PATCH 03/17] chore(ci): extract e2e tests to separate workflow (#2848) skip e2e on push to canary/integrations, only run on PRs, no merge queue --- .github/workflows/basic.yml | 85 ----------------------------- .github/workflows/e2e.yml | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 154de2b56..1bd4d8228 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -85,88 +85,3 @@ jobs: - name: Run Tests run: pnpm run test - - e2e-tests: - name: E2E Functional Tests (${{ matrix.name }}) - - runs-on: ubuntu-latest - - strategy: - matrix: - include: - - name: default - browsers: chromium webkit - test-filter: tests/ui/e2e - trailing-slash: true - locale-var: TESTS_LOCALE - artifact-name: playwright-report - - name: TRAILING_SLASH=false - browsers: chromium - test-filter: tests/ui/e2e --grep @no-trailing-slash - trailing-slash: false - locale-var: TESTS_LOCALE - artifact-name: playwright-report-no-trailing - - name: alternate locale - browsers: chromium - test-filter: tests/ui/e2e --grep @alternate-locale - trailing-slash: true - locale-var: TESTS_ALTERNATE_LOCALE - artifact-name: playwright-report-alternate-locale - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - uses: pnpm/action-setup@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: ".nvmrc" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps ${{ matrix.browsers }} - working-directory: ./core - - # - name: Build catalyst-client - # run: pnpm --filter @bigcommerce/catalyst-client build - - # - name: Build catalyst-core - # run: pnpm --filter @bigcommerce/catalyst-core build - - - name: Build catalyst - run: pnpm build - - - name: Start server - run: | - pnpm start & - npx wait-on http://localhost:3000 --timeout 60000 - working-directory: ./core - env: - PORT: 3000 - AUTH_SECRET: ${{ secrets.TESTS_AUTH_SECRET }} - AUTH_TRUST_HOST: ${{ vars.TESTS_AUTH_TRUST_HOST }} - BIGCOMMERCE_TRUSTED_PROXY_SECRET: ${{ secrets.BIGCOMMERCE_TRUSTED_PROXY_SECRET }} - TESTS_LOCALE: ${{ vars[matrix.locale-var] }} - TRAILING_SLASH: ${{ matrix.trailing-slash }} - DEFAULT_REVALIDATE_TARGET: ${{ matrix.name == 'default' && '1' || '' }} - - - name: Run E2E tests - run: pnpm exec playwright test ${{ matrix.test-filter }} - working-directory: ./core - env: - PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000 - - - name: Upload test results - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact-name }} - path: ./core/.tests/reports/ - retention-days: 3 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..64ca9b3df --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,104 @@ +name: E2E Tests + +on: + pull_request: + types: [opened, synchronize] + branches: [canary, integrations/makeswift, integrations/b2b-makeswift] + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + BIGCOMMERCE_STORE_HASH: ${{ vars.BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.BIGCOMMERCE_CHANNEL_ID }} + BIGCOMMERCE_CLIENT_ID: ${{ secrets.BIGCOMMERCE_CLIENT_ID }} + BIGCOMMERCE_CLIENT_SECRET: ${{ secrets.BIGCOMMERCE_CLIENT_SECRET }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.BIGCOMMERCE_STOREFRONT_TOKEN }} + BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} + TEST_CUSTOMER_ID: ${{ vars.TEST_CUSTOMER_ID }} + TEST_CUSTOMER_EMAIL: ${{ vars.TEST_CUSTOMER_EMAIL }} + TEST_CUSTOMER_PASSWORD: ${{ secrets.TEST_CUSTOMER_PASSWORD }} + TESTS_FALLBACK_LOCALE: ${{ vars.TESTS_FALLBACK_LOCALE }} + TESTS_READ_ONLY: ${{ vars.TESTS_READ_ONLY }} + DEFAULT_PRODUCT_ID: ${{ vars.DEFAULT_PRODUCT_ID }} + DEFAULT_COMPLEX_PRODUCT_ID: ${{ vars.DEFAULT_COMPLEX_PRODUCT_ID }} + +jobs: + e2e-tests: + name: E2E Functional Tests (${{ matrix.name }}) + + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - name: default + browsers: chromium webkit + test-filter: tests/ui/e2e + trailing-slash: true + locale-var: TESTS_LOCALE + artifact-name: playwright-report + - name: TRAILING_SLASH=false + browsers: chromium + test-filter: tests/ui/e2e --grep @no-trailing-slash + trailing-slash: false + locale-var: TESTS_LOCALE + artifact-name: playwright-report-no-trailing + - name: alternate locale + browsers: chromium + test-filter: tests/ui/e2e --grep @alternate-locale + trailing-slash: true + locale-var: TESTS_ALTERNATE_LOCALE + artifact-name: playwright-report-alternate-locale + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - uses: pnpm/action-setup@v3 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps ${{ matrix.browsers }} + working-directory: ./core + + - name: Build catalyst + run: pnpm build + + - name: Start server + run: | + pnpm start & + npx wait-on http://localhost:3000 --timeout 60000 + working-directory: ./core + env: + PORT: 3000 + AUTH_SECRET: ${{ secrets.TESTS_AUTH_SECRET }} + AUTH_TRUST_HOST: ${{ vars.TESTS_AUTH_TRUST_HOST }} + BIGCOMMERCE_TRUSTED_PROXY_SECRET: ${{ secrets.BIGCOMMERCE_TRUSTED_PROXY_SECRET }} + TESTS_LOCALE: ${{ vars[matrix.locale-var] }} + TRAILING_SLASH: ${{ matrix.trailing-slash }} + DEFAULT_REVALIDATE_TARGET: ${{ matrix.name == 'default' && '1' || '' }} + + - name: Run E2E tests + run: pnpm exec playwright test ${{ matrix.test-filter }} + working-directory: ./core + env: + PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000 + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: ./core/.tests/reports/ + retention-days: 3 From a7395f1a6778fe93080e8fcb05dce423cbc3acc0 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Thu, 29 Jan 2026 14:54:19 -0700 Subject: [PATCH 04/17] chore: use dompurify instead of isomorphic version (#2852) --- .changeset/gentle-badgers-dress.md | 22 ++++++ .../product-review-schema.tsx | 5 +- core/package.json | 2 +- pnpm-lock.yaml | 74 ++++++++++--------- 4 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 .changeset/gentle-badgers-dress.md diff --git a/.changeset/gentle-badgers-dress.md b/.changeset/gentle-badgers-dress.md new file mode 100644 index 000000000..5b2820376 --- /dev/null +++ b/.changeset/gentle-badgers-dress.md @@ -0,0 +1,22 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Uses regular `dompurify` (DP) instead of `isomorphic-dompurify` (IDP), because IDP requires JSDOM. JSDOM doesn't work in edge-runtime environments even with nodejs compatibility. We only need it on the client anyways for the JSON-LD schema, so it doesn't need the isomorphic aspect of it. This also changes `core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx` to be a client-component to enable `dompurify to work correctly. + +## Migration + +1. Remove the old dependency and add the new: +```bash +pnpm rm isomorphic-dompurify +pnpm add dompurify -S +``` + +2. Change the import in `core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx`: +```diff +- import DOMPurify from 'isomorphic-dompurify'; ++// eslint-disable-next-line import/no-named-as-default ++import DOMPurify from 'dompurify'; +``` + +3. Add the `'use client';` directive to the top of `core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx`. \ No newline at end of file diff --git a/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx index 15f1cd00c..21917a35e 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx @@ -1,4 +1,7 @@ -import DOMPurify from 'isomorphic-dompurify'; +'use client'; + +// eslint-disable-next-line import/no-named-as-default +import DOMPurify from 'dompurify'; import { useFormatter } from 'next-intl'; import { Product as ProductSchemaType, WithContext } from 'schema-dts'; diff --git a/core/package.json b/core/package.json index c1e19d825..3ada39fa5 100644 --- a/core/package.json +++ b/core/package.json @@ -52,7 +52,7 @@ "embla-carousel-react": "8.5.2", "gql.tada": "^1.8.10", "graphql": "^16.11.0", - "isomorphic-dompurify": "^2.25.0", + "dompurify": "^3.3.1", "jose": "^5.10.0", "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a329e092..dbc27dcfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: deepmerge: specifier: ^4.3.1 version: 4.3.1 + dompurify: + specifier: ^3.3.1 + version: 3.3.1 embla-carousel: specifier: 8.5.2 version: 8.5.2 @@ -149,9 +152,6 @@ importers: graphql: specifier: ^16.11.0 version: 16.11.0 - isomorphic-dompurify: - specifier: ^2.25.0 - version: 2.25.0 jose: specifier: ^5.10.0 version: 5.10.0 @@ -5847,8 +5847,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.2.6: - resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7157,10 +7157,6 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} - isomorphic-dompurify@2.25.0: - resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==} - engines: {node: '>=18'} - isomorphic-fetch@3.0.0: resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} @@ -10268,6 +10264,7 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + optional: true '@ast-grep/napi-darwin-arm64@0.35.0': optional: true @@ -11479,9 +11476,9 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30)(ts-node@10.9.2(@swc/core@1.11.31)(@types/node@22.15.30)(typescript@5.8.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) @@ -16891,6 +16888,7 @@ snapshots: dependencies: '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 + optional: true csstype@3.2.3: {} @@ -16902,6 +16900,7 @@ snapshots: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + optional: true data-view-buffer@1.0.2: dependencies: @@ -17061,7 +17060,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.2.6: + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -17370,8 +17369,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -17398,7 +17397,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -17409,7 +17408,7 @@ snapshots: stable-hash: 0.0.5 tinyglobby: 0.2.14 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17420,7 +17419,7 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17434,7 +17433,7 @@ snapshots: dependencies: gettext-parser: 4.2.0 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -18236,6 +18235,7 @@ snapshots: html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 + optional: true html-escaper@2.0.2: {} @@ -18475,7 +18475,8 @@ snapshots: is-plain-object@5.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-promise@4.0.0: {} @@ -18561,16 +18562,6 @@ snapshots: isexe@3.1.1: {} - isomorphic-dompurify@2.25.0: - dependencies: - dompurify: 3.2.6 - jsdom: 26.1.0 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - utf-8-validate - isomorphic-fetch@3.0.0(encoding@0.1.13): dependencies: node-fetch: 2.7.0(encoding@0.1.13) @@ -19012,6 +19003,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true jsesc@3.1.0: {} @@ -19624,7 +19616,8 @@ snapshots: optionalDependencies: next: 15.5.10(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - nwsapi@2.2.20: {} + nwsapi@2.2.20: + optional: true nypm@0.5.4: dependencies: @@ -20596,7 +20589,8 @@ snapshots: transitivePeerDependencies: - supports-color - rrweb-cssom@0.8.0: {} + rrweb-cssom@0.8.0: + optional: true rspack-resolver@1.2.2: optionalDependencies: @@ -20648,6 +20642,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true scheduler@0.26.0: {} @@ -21101,7 +21096,8 @@ snapshots: zimmerframe: 1.1.4 optional: true - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true synckit@0.11.8: dependencies: @@ -21243,6 +21239,7 @@ snapshots: tldts@6.1.86: dependencies: tldts-core: 6.1.86 + optional: true tmp@0.0.33: dependencies: @@ -21274,6 +21271,7 @@ snapshots: tough-cookie@5.1.2: dependencies: tldts: 6.1.86 + optional: true tr46@0.0.3: {} @@ -21284,6 +21282,7 @@ snapshots: tr46@5.1.1: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: {} @@ -21765,6 +21764,7 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true walker@1.0.8: dependencies: @@ -21776,7 +21776,8 @@ snapshots: webidl-conversions@4.0.2: {} - webidl-conversions@7.0.0: {} + webidl-conversions@7.0.0: + optional: true webpack-bundle-analyzer@4.10.1: dependencies: @@ -21811,6 +21812,7 @@ snapshots: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + optional: true whatwg-url@5.0.0: dependencies: @@ -21952,9 +21954,11 @@ snapshots: xdg-basedir@4.0.0: {} - xml-name-validator@5.0.0: {} + xml-name-validator@5.0.0: + optional: true - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true y18n@5.0.8: {} From 74dee6e6cafc57ea0e6eea94aafc4b38063352b1 Mon Sep 17 00:00:00 2001 From: Jordan Arldt Date: Fri, 30 Jan 2026 13:02:05 -0600 Subject: [PATCH 05/17] chore(catalyst): CATALYST-1267 Translate form field errors (#2844) * chore(catalyst): CATALYST-1267 Translate form field errors * chore(catalyst): CATALYST-1267 Output nextJS logs in test report folder * chore(catalyst): CATALYST-1267 Update forgot-password test translation --- .changeset/hot-files-start.md | 13 ++ .github/workflows/e2e.yml | 3 +- .../_actions/change-password.ts | 6 +- .../(auth)/change-password/page-data.ts | 39 ++++ .../(default)/(auth)/change-password/page.tsx | 4 + .../(default)/(auth)/register/page.tsx | 43 +++++ .../settings/_actions/change-password.ts | 10 +- .../(default)/account/settings/page-data.tsx | 14 ++ .../(default)/account/settings/page.tsx | 1 + .../cart/_actions/update-shipping-info.ts | 2 +- .../purchase/_actions/add-to-cart.tsx | 2 +- .../gift-certificates/purchase/page.tsx | 16 +- .../subscribe/_actions/subscribe.ts | 7 +- core/i18n/utils.ts | 29 +++ core/messages/en.json | 109 +++++++++-- .../tests/ui/e2e/auth/forgot-password.spec.ts | 3 +- core/vibes/soul/form/dynamic-form/index.tsx | 38 +++- core/vibes/soul/form/dynamic-form/schema.ts | 174 +++++++++++------- .../primitives/inline-email-form/index.tsx | 9 +- .../primitives/inline-email-form/schema.ts | 13 +- .../account-settings/change-password-form.tsx | 16 +- .../soul/sections/account-settings/index.tsx | 5 + .../soul/sections/account-settings/schema.ts | 102 +++++++--- .../account-settings/update-account-form.tsx | 18 +- .../sections/address-list-section/index.tsx | 7 +- .../sections/address-list-section/schema.ts | 30 +++ .../sections/cart/coupon-code-form/index.tsx | 13 +- .../cart/gift-certificate-code-form/index.tsx | 8 +- core/vibes/soul/sections/cart/schema.ts | 31 ++-- .../sections/cart/shipping-form/index.tsx | 11 +- .../sections/dynamic-form-section/index.tsx | 4 + .../forgot-password-form.tsx | 10 +- .../forgot-password-section/schema.ts | 15 +- .../index.tsx | 33 +++- .../sections/reset-password-section/index.tsx | 5 + .../reset-password-form.tsx | 14 +- .../sections/reset-password-section/schema.ts | 75 +++++--- core/vibes/soul/sections/reviews/index.tsx | 6 + .../soul/sections/reviews/review-form.tsx | 14 +- core/vibes/soul/sections/reviews/schema.ts | 27 +++ .../soul/sections/sign-in-section/schema.ts | 16 ++ .../sections/sign-in-section/sign-in-form.tsx | 10 +- 42 files changed, 803 insertions(+), 202 deletions(-) create mode 100644 .changeset/hot-files-start.md create mode 100644 core/app/[locale]/(default)/(auth)/change-password/page-data.ts create mode 100644 core/i18n/utils.ts diff --git a/.changeset/hot-files-start.md b/.changeset/hot-files-start.md new file mode 100644 index 000000000..26b01586d --- /dev/null +++ b/.changeset/hot-files-start.md @@ -0,0 +1,13 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Update forms to translate the form field validation errors + +## Migration + +Due to the amount of changes, it is recommended to just use the PR as a reference for migration. + +Detailed migration steps can be found on the PR here: +https://github.com/bigcommerce/catalyst/pull/2844 + diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 64ca9b3df..657f56257 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -77,7 +77,8 @@ jobs: - name: Start server run: | - pnpm start & + mkdir -p ./.tests/reports/ + pnpm start > ./.tests/reports/nextjs.app.log 2>&1 & npx wait-on http://localhost:3000 --timeout 60000 working-directory: ./core env: diff --git a/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts b/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts index 128b2e3b8..12bbaf388 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts +++ b/core/app/[locale]/(default)/(auth)/change-password/_actions/change-password.ts @@ -4,8 +4,8 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { SubmissionResult } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; -import { schema } from '@/vibes/soul/sections/reset-password-section/schema'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; @@ -25,6 +25,10 @@ const ChangePasswordMutation = graphql(` } `); +const schema = z.object({ + password: z.string(), +}); + export async function changePassword( { token, customerEntityId }: { token: string; customerEntityId: string }, _prevState: { lastResult: SubmissionResult | null; successMessage?: string }, diff --git a/core/app/[locale]/(default)/(auth)/change-password/page-data.ts b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts new file mode 100644 index 000000000..43a72f2d3 --- /dev/null +++ b/core/app/[locale]/(default)/(auth)/change-password/page-data.ts @@ -0,0 +1,39 @@ +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +const ChangePasswordQuery = graphql(` + query ChangePasswordQuery { + site { + settings { + customers { + passwordComplexitySettings { + minimumNumbers + minimumPasswordLength + minimumSpecialCharacters + requireLowerCase + requireNumbers + requireSpecialCharacters + requireUpperCase + } + } + } + } + } +`); + +export const getChangePasswordQuery = cache(async () => { + const response = await client.fetch({ + document: ChangePasswordQuery, + fetchOptions: { next: { revalidate } }, + }); + + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; + + return { + passwordComplexitySettings, + }; +}); diff --git a/core/app/[locale]/(default)/(auth)/change-password/page.tsx b/core/app/[locale]/(default)/(auth)/change-password/page.tsx index 944f091c6..1e7e251a6 100644 --- a/core/app/[locale]/(default)/(auth)/change-password/page.tsx +++ b/core/app/[locale]/(default)/(auth)/change-password/page.tsx @@ -3,6 +3,7 @@ import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { ResetPasswordSection } from '@/vibes/soul/sections/reset-password-section'; +import { getChangePasswordQuery } from '~/app/[locale]/(default)/(auth)/change-password/page-data'; import { redirect } from '~/i18n/routing'; import { changePassword } from './_actions/change-password'; @@ -37,11 +38,14 @@ export default async function ChangePassword({ params, searchParams }: Props) { return redirect({ href: '/login', locale }); } + const { passwordComplexitySettings } = await getChangePasswordQuery(); + return ( ); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index 1aeaeb9b5..bf8ebf135 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -109,6 +109,49 @@ export default async function Register({ params }: Props) { return ( { const t = await getTranslations('Account.Settings'); const customerAccessToken = await getSessionCustomerAccessToken(); - - const submission = parseWithZod(formData, { schema: changePasswordSchema }); + const submission = parseWithZod(formData, { schema }); if (submission.status !== 'success') { return { lastResult: submission.reply() }; diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index f0db7b0f6..136ef5c99 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -35,6 +35,17 @@ const AccountSettingsQuery = graphql( newsletter { showNewsletterSignup } + customers { + passwordComplexitySettings { + minimumNumbers + minimumPasswordLength + minimumSpecialCharacters + requireLowerCase + requireNumbers + requireSpecialCharacters + requireUpperCase + } + } } } } @@ -75,6 +86,8 @@ export const getAccountSettingsQuery = cache(async ({ address, customer }: Props const customerFields = response.data.site.settings?.formFields.customer; const customerInfo = response.data.customer; const newsletterSettings = response.data.site.settings?.newsletter; + const passwordComplexitySettings = + response.data.site.settings?.customers?.passwordComplexitySettings; if (!addressFields || !customerFields || !customerInfo) { return null; @@ -85,5 +98,6 @@ export const getAccountSettingsQuery = cache(async ({ address, customer }: Props customerFields, customerInfo, newsletterSettings, + passwordComplexitySettings, }; }); diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index 6d074e943..cad145dc6 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -61,6 +61,7 @@ export default async function Settings({ params }: Props) { newsletterSubscriptionEnabled={newsletterSubscriptionEnabled} newsletterSubscriptionLabel={t('NewsletterSubscription.label')} newsletterSubscriptionTitle={t('NewsletterSubscription.title')} + passwordComplexitySettings={accountSettings.passwordComplexitySettings} title={t('title')} updateAccountAction={updateCustomer} updateAccountSubmitLabel={t('cta')} diff --git a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts index bb1c1d34a..662ba0f7a 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts @@ -23,7 +23,7 @@ export const updateShippingInfo = async ( const t = await getTranslations('Cart.CheckoutSummary.Shipping'); const submission = parseWithZod(formData, { - schema: shippingActionFormDataSchema, + schema: shippingActionFormDataSchema({ required_error: t('countryRequired') }), }); const cartId = await getCartId(); diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx index 10f05d178..49047ba14 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx @@ -41,7 +41,7 @@ const GiftCertificateSettingsQuery = graphql( const schema = ( giftCertificateSettings: ResultOf | undefined, - t: ExistingResultType, + t: ExistingResultType>, ) => { return z .object({ diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx index 07edaab6a..74f60684f 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -18,20 +18,20 @@ interface Props { function getFields( giftCertificateSettings: ResultOf, expiresAt: string | undefined, - t: ExistingResultType, + t: ExistingResultType>, ): Array> { const baseFields: Array> = [ [ { type: 'text', name: 'senderName', - label: `${t('Purchase.Form.senderNameLabel')} *`, + label: t('Purchase.Form.senderNameLabel'), required: true, }, { type: 'email', name: 'senderEmail', - label: `${t('Purchase.Form.senderEmailLabel')} *`, + label: t('Purchase.Form.senderEmailLabel'), required: true, }, ], @@ -39,13 +39,13 @@ function getFields( { type: 'text', name: 'recipientName', - label: `${t('Purchase.Form.recipientNameLabel')} *`, + label: t('Purchase.Form.recipientNameLabel'), required: true, }, { type: 'email', name: 'recipientEmail', - label: `${t('Purchase.Form.recipientEmailLabel')} *`, + label: t('Purchase.Form.recipientEmailLabel'), required: true, }, ], @@ -78,10 +78,10 @@ function getFields( { type: 'text', name: 'amount', - label: `${t('Purchase.Form.customAmountLabel', { + label: t('Purchase.Form.customAmountLabel', { minAmount: String(giftCertificateSettings.minimumAmount.value), maxAmount: String(giftCertificateSettings.maximumAmount.value), - })} *`, + }), pattern: '^[0-9]*\\.?[0-9]+$', required: true, }, @@ -90,7 +90,7 @@ function getFields( { type: 'select', name: 'amount', - label: `${t('Purchase.Form.amountLabel')} *`, + label: t('Purchase.Form.amountLabel'), defaultValue: '0', options: [ { diff --git a/core/components/subscribe/_actions/subscribe.ts b/core/components/subscribe/_actions/subscribe.ts index 62a82ec77..f4470c138 100644 --- a/core/components/subscribe/_actions/subscribe.ts +++ b/core/components/subscribe/_actions/subscribe.ts @@ -35,8 +35,11 @@ export const subscribe = async ( formData: FormData, ) => { const t = await getTranslations('Components.Subscribe'); - - const submission = parseWithZod(formData, { schema }); + const subscribeSchema = schema({ + requiredMessage: t('Errors.emailRequired'), + invalidMessage: t('Errors.invalidEmail'), + }); + const submission = parseWithZod(formData, { schema: subscribeSchema }); if (submission.status !== 'success') { return { lastResult: submission.reply() }; diff --git a/core/i18n/utils.ts b/core/i18n/utils.ts new file mode 100644 index 000000000..a395d5c41 --- /dev/null +++ b/core/i18n/utils.ts @@ -0,0 +1,29 @@ +import { parseWithZod as conformParseWithZod } from '@conform-to/zod'; +import { z, ZodIssueOptionalMessage } from 'zod'; + +import { FormErrorTranslationMap } from '@/vibes/soul/form/dynamic-form/schema'; + +export function createErrorMap(errorTranslations?: FormErrorTranslationMap) { + return (issue: ZodIssueOptionalMessage) => { + const field = issue.path[0]; + const fieldKey = typeof field === 'string' ? field : ''; + const errorMessage = errorTranslations?.[fieldKey]?.[issue.code]; + + return { message: errorMessage ?? issue.message ?? 'Invalid input' }; + }; +} + +export function parseWithZodTranslatedErrors( + formData: FormData, + options: { + schema: Schema; + errorTranslations?: FormErrorTranslationMap; + }, +) { + const errorMap = createErrorMap(options.errorTranslations); + + return conformParseWithZod(formData, { + schema: options.schema, + errorMap, + }); +} diff --git a/core/messages/en.json b/core/messages/en.json index 640f4216a..303e03f42 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -43,7 +43,17 @@ "newPassword": "New password", "confirmPassword": "Confirm password", "passwordUpdated": "Password has been updated successfully!", - "somethingWentWrong": "Something went wrong. Please try again later." + "somethingWentWrong": "Something went wrong. Please try again later.", + "FieldErrors": { + "passwordRequired": "Password is required", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" + } }, "Login": { "title": "Login", @@ -56,6 +66,12 @@ "somethingWentWrong": "Something went wrong. Please try again later.", "passwordResetRequired": "Password reset required. Please check your email for instructions to reset your password.", "invalidToken": "Your login link is invalid or has expired. Please try logging in again.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Password is required", + "invalidInput": "Please check your input and try again." + }, "CreateAccount": { "title": "New customer?", "accountBenefits": "Create an account with us and you'll be able to:", @@ -70,14 +86,36 @@ "title": "Forgot password", "subtitle": "Enter the email associated with your account below. We'll send you instructions to reset your password.", "confirmResetPassword": "If the email address {email} is linked to an account in our store, we have sent you a password reset email. Please check your inbox and spam folder if you don't see it.", - "somethingWentWrong": "Something went wrong. Please try again later." + "somethingWentWrong": "Something went wrong. Please try again later.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address" + } } }, "Register": { "title": "Register account", "heading": "New account", "cta": "Create account", - "somethingWentWrong": "Something went wrong. Please try again later." + "somethingWentWrong": "Something went wrong. Please try again later.", + "FieldErrors": { + "firstNameRequired": "First name is required", + "lastNameRequired": "Last name is required", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Password is required", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "City is required", + "countryRequired": "Country is required", + "stateRequired": "State/Province is required", + "postalCodeRequired": "Postal code is required" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Something went wrong. Please try again later.", "EmptyState": { "title": "You don't have any addresses" + }, + "FieldErrors": { + "firstNameRequired": "First name is required", + "lastNameRequired": "Last name is required", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "City is required", + "countryRequired": "Country is required", + "stateRequired": "State/Province is required", + "postalCodeRequired": "Postal code is required" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Subscribe to our newsletter.", "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!", "somethingWentWrong": "Something went wrong. Please try again later." + }, + "FieldErrors": { + "firstNameRequired": "First name is required", + "firstNameTooSmall": "First name must be at least 2 characters long", + "lastNameRequired": "Last name is required", + "lastNameTooSmall": "Last name must be at least 2 characters long", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "currentPasswordRequired": "Current password is required", + "passwordRequired": "Password is required", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" } } }, @@ -335,7 +399,8 @@ "updateShipping": "Update shipping", "addShipping": "Add shipping", "cartNotFound": "An error occurred when retrieving your cart", - "noShippingOptions": "There are no shipping options available for your address" + "noShippingOptions": "There are no shipping options available for your address", + "countryRequired": "Country is required" } }, "GiftCertificate": { @@ -427,13 +492,24 @@ "button": "Write a review", "title": "Write a review", "submit": "Submit", + "cancel": "Cancel", "ratingLabel": "Rating", "titleLabel": "Title", "reviewLabel": "Review", "nameLabel": "Name", "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", - "somethingWentWrong": "Something went wrong. Please try again later." + "somethingWentWrong": "Something went wrong. Please try again later.", + "FieldErrors": { + "titleRequired": "Title is required", + "authorRequired": "Name is required", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "textRequired": "Review is required", + "ratingRequired": "Rating is required", + "ratingTooSmall": "Rating must be at least 1", + "ratingTooLarge": "Rating must be at most 5" + } } } }, @@ -515,7 +591,8 @@ "description": "Stay up to date with the latest news and offers from our store.", "subscribedToNewsletter": "You have been subscribed to our newsletter!", "Errors": { - "invalidEmail": "Please enter a valid email address.", + "emailRequired": "Email is required", + "invalidEmail": "Please enter a valid email address", "somethingWentWrong": "Something went wrong. Please try again later." } }, @@ -607,15 +684,25 @@ "expiryCheckboxLabel": "I acknowledge that this Gift Certificate will expire on {expiryDate}", "ctaLabel": "Add to cart", "Errors": { - "amountRequired": "Please select or enter a gift certificate amount.", - "amountInvalid": "Please select a valid gift certificate amount.", - "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}.", - "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later." + "amountRequired": "Please select or enter a gift certificate amount", + "amountInvalid": "Please select a valid gift certificate amount", + "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}", + "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later.", + "senderNameRequired": "Your name is required", + "senderEmailRequired": "Your email is required", + "recipientNameRequired": "Recipient's name is required", + "recipientEmailRequired": "Recipient's email is required", + "emailInvalid": "Please enter a valid email address", + "checkboxRequired": "You must check this box to continue" } } } }, "Form": { - "optional": "optional" + "optional": "optional", + "Errors": { + "invalidInput": "Please check your input and try again", + "invalidFormat": "The value entered does not match the required format" + } } } diff --git a/core/tests/ui/e2e/auth/forgot-password.spec.ts b/core/tests/ui/e2e/auth/forgot-password.spec.ts index a8a68faed..32cafede5 100644 --- a/core/tests/ui/e2e/auth/forgot-password.spec.ts +++ b/core/tests/ui/e2e/auth/forgot-password.spec.ts @@ -26,6 +26,5 @@ test('Forgot password form displays error if email is not valid', async ({ page await page.getByLabel('Email').fill('not-an-email'); await page.getByRole('button', { name: 'Reset password' }).click(); - // TODO: Forgot password form error message needs to be translated - await expect(page.getByText('Please enter a valid email.')).toBeVisible(); + await expect(page.getByText(t('FieldErrors.emailInvalid'))).toBeVisible(); }); diff --git a/core/vibes/soul/form/dynamic-form/index.tsx b/core/vibes/soul/form/dynamic-form/index.tsx index 620dff9d0..53c21b1d5 100644 --- a/core/vibes/soul/form/dynamic-form/index.tsx +++ b/core/vibes/soul/form/dynamic-form/index.tsx @@ -11,6 +11,7 @@ import { useInputControl, } from '@conform-to/react'; import { getZodConstraint, parseWithZod } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { FormEvent, MouseEvent, @@ -36,7 +37,13 @@ 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 { Field, FieldGroup, PasswordComplexitySettings, schema } from './schema'; +import { + Field, + FieldGroup, + FormErrorTranslationMap, + PasswordComplexitySettings, + schema, +} from './schema'; import { removeOptionsFromFields } from './utils'; export interface DynamicFormActionArgs { @@ -69,6 +76,7 @@ export interface DynamicFormProps { onChange?: (e: FormEvent) => void; onSuccess?: (lastResult: SubmissionResult, successMessage: ReactNode) => void; passwordComplexity?: PasswordComplexitySettings | null; + errorTranslations?: FormErrorTranslationMap; } export function DynamicForm({ @@ -83,7 +91,9 @@ export function DynamicForm({ onChange, onSuccess, passwordComplexity, + errorTranslations, }: DynamicFormProps) { + const t = useTranslations('Form'); // Remove options from fields before passing to action to reduce payload size // Options are only needed for rendering, not for processing form submissions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -94,7 +104,7 @@ export function DynamicForm({ lastResult: null, }); - const dynamicSchema = schema(fields, passwordComplexity); + const dynamicSchema = schema(fields, passwordComplexity, errorTranslations); const defaultValue = fields .flatMap((f) => (Array.isArray(f) ? f : [f])) .reduce>( @@ -104,11 +114,33 @@ export function DynamicForm({ }), {}, ); + const [form, formFields] = useForm({ lastResult, constraint: getZodConstraint(dynamicSchema), onValidate({ formData }) { - return parseWithZod(formData, { schema: dynamicSchema }); + return parseWithZod(formData, { + schema: dynamicSchema, + errorMap: (issue) => { + if ( + !errorTranslations && + issue.code === z.ZodIssueCode.invalid_string && + issue.validation === 'regex' + ) { + return { message: t('Errors.invalidFormat') }; + } + + if (!errorTranslations) { + return { message: issue.message ?? t('Errors.invalidInput') }; + } + + const field = issue.path[0]; + const fieldKey = typeof field === 'string' ? field : ''; + const errorMessage = errorTranslations[fieldKey]?.[issue.code]; + + return { message: errorMessage ?? issue.message ?? t('Errors.invalidInput') }; + }, + }); }, defaultValue, shouldValidate: 'onSubmit', diff --git a/core/vibes/soul/form/dynamic-form/schema.ts b/core/vibes/soul/form/dynamic-form/schema.ts index 4b92f5d2b..f14f19a76 100644 --- a/core/vibes/soul/form/dynamic-form/schema.ts +++ b/core/vibes/soul/form/dynamic-form/schema.ts @@ -10,6 +10,21 @@ export interface PasswordComplexitySettings { requireUpperCase?: boolean | null; } +export type FormErrorTranslationMap = Record< + string, + Partial< + Record< + | z.ZodIssueCode + | 'lowercase_required' + | 'uppercase_required' + | 'number_required' + | 'special_character_required' + | 'passwords_must_match', + string + > + > +>; + interface FormField { name: string; label?: string; @@ -159,17 +174,94 @@ export type SchemaRawShape = Record< | z.ZodOptional | z.ZodArray | z.ZodOptional> + | z.ZodLiteral<'true'> + | z.ZodEnum<['true', 'false']> + | z.ZodOptional> >; // eslint-disable-next-line complexity -function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySettings | null) { +export function getPasswordSchema( + passwordComplexity?: PasswordComplexitySettings | null, + errorTranslations?: FormErrorTranslationMap, +) { + const minLength = passwordComplexity?.minimumPasswordLength ?? 8; + const minNumbers = passwordComplexity?.minimumNumbers ?? 0; + const minSpecialChars = passwordComplexity?.minimumSpecialCharacters ?? 0; + const requireLowerCase = passwordComplexity?.requireLowerCase ?? false; + const requireUpperCase = passwordComplexity?.requireUpperCase ?? false; + const requireNumbers = passwordComplexity?.requireNumbers ?? true; + const requireSpecialChars = passwordComplexity?.requireSpecialCharacters ?? true; + + let fieldSchema = z.string().trim(); + + fieldSchema = fieldSchema.min(minLength); + + if (requireLowerCase) { + fieldSchema = fieldSchema.regex(/[a-z]/, { + message: + errorTranslations?.password?.lowercase_required ?? 'Contain at least one lowercase letter', + }); + } + + if (requireUpperCase) { + fieldSchema = fieldSchema.regex(/[A-Z]/, { + message: + errorTranslations?.password?.uppercase_required ?? 'Contain at least one uppercase letter', + }); + } + + if (requireNumbers && minNumbers > 0) { + const numberRegex = new RegExp(`(.*[0-9]){${minNumbers},}`); + + fieldSchema = fieldSchema.regex(numberRegex, { + message: + errorTranslations?.password?.number_required ?? + (minNumbers === 1 + ? 'Contain at least one number' + : `Contain at least ${minNumbers} numbers`), + }); + } else if (requireNumbers) { + fieldSchema = fieldSchema.regex(/[0-9]/, { + message: errorTranslations?.password?.number_required ?? 'Contain at least one number', + }); + } + + if (requireSpecialChars && minSpecialChars > 0) { + const specialCharRegex = new RegExp(`(.*[^a-zA-Z0-9]){${minSpecialChars},}`); + + fieldSchema = fieldSchema.regex(specialCharRegex, { + message: + errorTranslations?.password?.special_character_required ?? + (minSpecialChars === 1 + ? 'Contain at least one special character' + : `Contain at least ${minSpecialChars} special characters`), + }); + } else if (requireSpecialChars) { + fieldSchema = fieldSchema.regex(/[^a-zA-Z0-9]/, { + message: + errorTranslations?.password?.special_character_required ?? + 'Contain at least one special character', + }); + } + + return fieldSchema; +} + +function getFieldSchema( + field: Field, + passwordComplexity?: PasswordComplexitySettings | null, + errorTranslations?: FormErrorTranslationMap, +) { let fieldSchema: | z.ZodString | z.ZodNumber + | z.ZodLiteral<'true'> | z.ZodOptional | z.ZodOptional + | z.ZodOptional> | z.ZodArray - | z.ZodOptional>; + | z.ZodOptional> + | z.ZodOptional>; switch (field.type) { case 'number': @@ -185,9 +277,7 @@ function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySet fieldSchema = z.string(); if (field.pattern != null) { - fieldSchema = fieldSchema.regex(new RegExp(field.pattern), { - message: 'Invalid format.', - }); + fieldSchema = fieldSchema.regex(new RegExp(field.pattern)); } if (field.required !== true) fieldSchema = fieldSchema.optional(); @@ -195,61 +285,7 @@ function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySet break; case 'password': { - const minLength = passwordComplexity?.minimumPasswordLength ?? 8; - const minNumbers = passwordComplexity?.minimumNumbers ?? 0; - const minSpecialChars = passwordComplexity?.minimumSpecialCharacters ?? 0; - const requireLowerCase = passwordComplexity?.requireLowerCase ?? false; - const requireUpperCase = passwordComplexity?.requireUpperCase ?? false; - const requireNumbers = passwordComplexity?.requireNumbers ?? true; - const requireSpecialChars = passwordComplexity?.requireSpecialCharacters ?? true; - - fieldSchema = z.string().trim(); - - fieldSchema = fieldSchema.min(minLength, { - message: `Be at least ${minLength} character${minLength !== 1 ? 's' : ''} long`, - }); - - if (requireLowerCase) { - fieldSchema = fieldSchema.regex(/[a-z]/, { - message: 'Contain at least one lowercase letter.', - }); - } - - if (requireUpperCase) { - fieldSchema = fieldSchema.regex(/[A-Z]/, { - message: 'Contain at least one uppercase letter.', - }); - } - - if (requireNumbers && minNumbers > 0) { - const numberRegex = new RegExp(`(.*[0-9]){${minNumbers},}`); - - fieldSchema = fieldSchema.regex(numberRegex, { - message: - minNumbers === 1 - ? 'Contain at least one number.' - : `Contain at least ${minNumbers} numbers.`, - }); - } else if (requireNumbers) { - fieldSchema = fieldSchema.regex(/[0-9]/, { - message: 'Contain at least one number.', - }); - } - - if (requireSpecialChars && minSpecialChars > 0) { - const specialCharRegex = new RegExp(`(.*[^a-zA-Z0-9]){${minSpecialChars},}`); - - fieldSchema = fieldSchema.regex(specialCharRegex, { - message: - minSpecialChars === 1 - ? 'Contain at least one special character.' - : `Contain at least ${minSpecialChars} special characters.`, - }); - } else if (requireSpecialChars) { - fieldSchema = fieldSchema.regex(/[^a-zA-Z0-9]/, { - message: 'Contain at least one special character.', - }); - } + fieldSchema = getPasswordSchema(passwordComplexity, errorTranslations); if (field.required !== true) fieldSchema = fieldSchema.optional(); @@ -257,7 +293,7 @@ function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySet } case 'email': - fieldSchema = z.string().email({ message: 'Please enter a valid email.' }).trim(); + fieldSchema = z.string().email().trim(); if (field.required !== true) fieldSchema = fieldSchema.optional(); @@ -270,6 +306,15 @@ function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySet break; + case 'checkbox': + if (field.required === true) { + fieldSchema = z.literal('true'); + } else { + fieldSchema = z.enum(['true', 'false']).optional(); + } + + break; + default: fieldSchema = z.string(); @@ -282,6 +327,7 @@ function getFieldSchema(field: Field, passwordComplexity?: PasswordComplexitySet export function schema( fields: Array>, passwordComplexity?: PasswordComplexitySettings | null, + errorTranslations?: FormErrorTranslationMap, ) { const shape: SchemaRawShape = {}; let passwordFieldName: string | undefined; @@ -290,13 +336,13 @@ export function schema( fields.forEach((field) => { if (Array.isArray(field)) { field.forEach((f) => { - shape[f.name] = getFieldSchema(f, passwordComplexity); + shape[f.name] = getFieldSchema(f, passwordComplexity, errorTranslations); if (f.type === 'password') passwordFieldName = f.name; if (f.type === 'confirm-password') confirmPasswordFieldName = f.name; }); } else { - shape[field.name] = getFieldSchema(field, passwordComplexity); + shape[field.name] = getFieldSchema(field, passwordComplexity, errorTranslations); if (field.type === 'password') passwordFieldName = field.name; if (field.type === 'confirm-password') confirmPasswordFieldName = field.name; @@ -311,7 +357,7 @@ export function schema( ) { ctx.addIssue({ code: 'custom', - message: 'The passwords did not match', + message: errorTranslations?.password?.passwords_must_match ?? 'The passwords do not match', path: [confirmPasswordFieldName], }); } diff --git a/core/vibes/soul/primitives/inline-email-form/index.tsx b/core/vibes/soul/primitives/inline-email-form/index.tsx index b6f848ce5..9124585fd 100644 --- a/core/vibes/soul/primitives/inline-email-form/index.tsx +++ b/core/vibes/soul/primitives/inline-email-form/index.tsx @@ -4,6 +4,7 @@ import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform import { parseWithZod } from '@conform-to/zod'; import { clsx } from 'clsx'; import { ArrowRight } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useActionState } from 'react'; import { FieldError } from '@/vibes/soul/form/field-error'; @@ -28,6 +29,12 @@ export function InlineEmailForm({ submitLabel?: string; action: Action<{ lastResult: SubmissionResult | null; successMessage?: string }, FormData>; }) { + const t = useTranslations('Components.Subscribe'); + const subscribeSchema = schema({ + requiredMessage: t('Errors.emailRequired'), + invalidMessage: t('Errors.invalidEmail'), + }); + const [{ lastResult, successMessage }, formAction, isPending] = useActionState(action, { lastResult: null, }); @@ -35,7 +42,7 @@ export function InlineEmailForm({ const [form, fields] = useForm({ lastResult, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseWithZod(formData, { schema: subscribeSchema }); }, shouldValidate: 'onSubmit', shouldRevalidate: 'onInput', diff --git a/core/vibes/soul/primitives/inline-email-form/schema.ts b/core/vibes/soul/primitives/inline-email-form/schema.ts index 00c2c7042..57aa5f018 100644 --- a/core/vibes/soul/primitives/inline-email-form/schema.ts +++ b/core/vibes/soul/primitives/inline-email-form/schema.ts @@ -1,5 +1,12 @@ import { z } from 'zod'; -export const schema = z.object({ - email: z.string().email(), -}); +export const schema = ({ + requiredMessage = 'Email is required', + invalidMessage = 'Please enter a valid email address', +}: { + requiredMessage: string; + invalidMessage: string; +}) => + z.object({ + email: z.string({ required_error: requiredMessage }).email({ message: invalidMessage }), + }); diff --git a/core/vibes/soul/sections/account-settings/change-password-form.tsx b/core/vibes/soul/sections/account-settings/change-password-form.tsx index c2323516d..809a01e64 100644 --- a/core/vibes/soul/sections/account-settings/change-password-form.tsx +++ b/core/vibes/soul/sections/account-settings/change-password-form.tsx @@ -1,15 +1,18 @@ 'use client'; import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; -import { getZodConstraint, parseWithZod } from '@conform-to/zod'; +import { getZodConstraint } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { ReactNode, useActionState, useEffect } from 'react'; import { useFormStatus } from 'react-dom'; +import { PasswordComplexitySettings } from '@/vibes/soul/form/dynamic-form/schema'; import { Input } from '@/vibes/soul/form/input'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; +import { parseWithZodTranslatedErrors } from '~/i18n/utils'; -import { changePasswordSchema } from './schema'; +import { changePasswordErrorTranslations, changePasswordSchema } from './schema'; type Action = (state: Awaited, payload: P) => S | Promise; @@ -26,6 +29,7 @@ export interface ChangePasswordFormProps { newPasswordLabel?: string; confirmPasswordLabel?: string; submitLabel?: string; + passwordComplexitySettings?: PasswordComplexitySettings | null; } export function ChangePasswordForm({ @@ -34,14 +38,18 @@ export function ChangePasswordForm({ newPasswordLabel = 'New password', confirmPasswordLabel = 'Confirm password', submitLabel = 'Update', + passwordComplexitySettings, }: ChangePasswordFormProps) { + const t = useTranslations('Account.Settings'); + const errorTranslations = changePasswordErrorTranslations(t, passwordComplexitySettings); + const schema = changePasswordSchema(passwordComplexitySettings, errorTranslations); const [state, formAction] = useActionState(action, { lastResult: null }); const [form, fields] = useForm({ - constraint: getZodConstraint(changePasswordSchema), + constraint: getZodConstraint(schema), shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { - return parseWithZod(formData, { schema: changePasswordSchema }); + return parseWithZodTranslatedErrors(formData, { schema, errorTranslations }); }, }); diff --git a/core/vibes/soul/sections/account-settings/index.tsx b/core/vibes/soul/sections/account-settings/index.tsx index 8c6377d62..d8e52712c 100644 --- a/core/vibes/soul/sections/account-settings/index.tsx +++ b/core/vibes/soul/sections/account-settings/index.tsx @@ -1,3 +1,5 @@ +import { PasswordComplexitySettings } from '@/vibes/soul/form/dynamic-form/schema'; + import { ChangePasswordAction, ChangePasswordForm } from './change-password-form'; import { NewsletterSubscriptionForm, @@ -22,6 +24,7 @@ export interface AccountSettingsSectionProps { newsletterSubscriptionLabel?: string; newsletterSubscriptionCtaLabel?: string; updateNewsletterSubscriptionAction?: UpdateNewsletterSubscriptionAction; + passwordComplexitySettings?: PasswordComplexitySettings | null; } // eslint-disable-next-line valid-jsdoc @@ -55,6 +58,7 @@ export function AccountSettingsSection({ newsletterSubscriptionLabel = 'Opt-in to receive emails about new products and promotions.', newsletterSubscriptionCtaLabel = 'Save preferences', updateNewsletterSubscriptionAction, + passwordComplexitySettings, }: AccountSettingsSectionProps) { return (
@@ -81,6 +85,7 @@ export function AccountSettingsSection({ confirmPasswordLabel={confirmPasswordLabel} currentPasswordLabel={currentPasswordLabel} newPasswordLabel={newPasswordLabel} + passwordComplexitySettings={passwordComplexitySettings} submitLabel={changePasswordSubmitLabel} /> diff --git a/core/vibes/soul/sections/account-settings/schema.ts b/core/vibes/soul/sections/account-settings/schema.ts index 7ce11b811..5fe66feea 100644 --- a/core/vibes/soul/sections/account-settings/schema.ts +++ b/core/vibes/soul/sections/account-settings/schema.ts @@ -1,32 +1,82 @@ +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; +import { + FormErrorTranslationMap, + getPasswordSchema, + PasswordComplexitySettings, +} from '@/vibes/soul/form/dynamic-form/schema'; +import { ExistingResultType } from '~/client/util'; + export const updateAccountSchema = z.object({ - firstName: z.string().min(2, { message: 'Name must be at least 2 characters long.' }).trim(), - lastName: z.string().min(2, { message: 'Name must be at least 2 characters long.' }).trim(), - email: z.string().email({ message: 'Please enter a valid email.' }).trim(), + firstName: z.string().min(2).trim(), + lastName: z.string().min(2).trim(), + email: z.string().email().trim(), company: z.string().trim().optional(), }); -export const changePasswordSchema = z - .object({ - currentPassword: z.string().trim(), - password: z - .string() - .min(8, { message: 'Be at least 8 characters long' }) - .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' }) - .regex(/[0-9]/, { message: 'Contain at least one number.' }) - .regex(/[^a-zA-Z0-9]/, { - message: 'Contain at least one special character.', - }) - .trim(), - confirmPassword: z.string(), - }) - .superRefine(({ confirmPassword, password }, ctx) => { - if (confirmPassword !== password) { - ctx.addIssue({ - code: 'custom', - message: 'The passwords did not match', - path: ['confirmPassword'], - }); - } - }); +export const updateAccountErrorTranslations = ( + t: ExistingResultType>, +): FormErrorTranslationMap => ({ + firstName: { + invalid_type: t('FieldErrors.firstNameRequired'), + too_small: t('FieldErrors.firstNameTooSmall'), + }, + lastName: { + invalid_type: t('FieldErrors.lastNameRequired'), + too_small: t('FieldErrors.lastNameTooSmall'), + }, + email: { + invalid_type: t('FieldErrors.emailRequired'), + invalid_string: t('FieldErrors.emailInvalid'), + }, +}); + +export const changePasswordErrorTranslations = ( + t: ExistingResultType>, + passwordComplexity?: PasswordComplexitySettings | null, +): FormErrorTranslationMap => ({ + currentPassword: { + invalid_type: t('FieldErrors.currentPasswordRequired'), + }, + password: { + invalid_type: t('FieldErrors.passwordRequired'), + too_small: t('FieldErrors.passwordTooSmall', { + minLength: passwordComplexity?.minimumPasswordLength ?? 0, + }), + lowercase_required: t('FieldErrors.passwordLowercaseRequired'), + uppercase_required: t('FieldErrors.passwordUppercaseRequired'), + number_required: t('FieldErrors.passwordNumberRequired', { + minNumbers: passwordComplexity?.minimumNumbers ?? 1, + }), + special_character_required: t('FieldErrors.passwordSpecialCharacterRequired'), + passwords_must_match: t('FieldErrors.passwordsMustMatch'), + }, + confirmPassword: { + invalid_type: t('FieldErrors.confirmPasswordRequired'), + }, +}); + +export const changePasswordSchema = ( + passwordComplexity?: PasswordComplexitySettings | null, + errorTranslations?: FormErrorTranslationMap, +) => { + const passwordSchema = getPasswordSchema(passwordComplexity, errorTranslations); + + return z + .object({ + currentPassword: z.string().trim(), + password: passwordSchema, + confirmPassword: z.string(), + }) + .superRefine(({ confirmPassword, password }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + code: 'custom', + message: + errorTranslations?.password?.passwords_must_match ?? 'The passwords do not match', + path: ['confirmPassword'], + }); + } + }); +}; diff --git a/core/vibes/soul/sections/account-settings/update-account-form.tsx b/core/vibes/soul/sections/account-settings/update-account-form.tsx index 3e7f9bf58..44bfb6bd3 100644 --- a/core/vibes/soul/sections/account-settings/update-account-form.tsx +++ b/core/vibes/soul/sections/account-settings/update-account-form.tsx @@ -1,15 +1,17 @@ 'use client'; import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; -import { getZodConstraint, parseWithZod } from '@conform-to/zod'; +import { getZodConstraint } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { useActionState, useEffect, useOptimistic, useTransition } from 'react'; import { z } from 'zod'; import { Input } from '@/vibes/soul/form/input'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; +import { parseWithZodTranslatedErrors } from '~/i18n/utils'; -import { updateAccountSchema } from './schema'; +import { updateAccountErrorTranslations, updateAccountSchema } from './schema'; type Action = (state: Awaited, payload: P) => S | Promise; @@ -42,6 +44,8 @@ export function UpdateAccountForm({ companyLabel = 'Company', submitLabel = 'Update', }: UpdateAccountFormProps) { + const t = useTranslations('Account.Settings'); + const errorTranslations = updateAccountErrorTranslations(t); const [state, formAction] = useActionState(action, { account, lastResult: null }); const [pending, startTransition] = useTransition(); @@ -49,7 +53,10 @@ export function UpdateAccountForm({ state, (prevState, formData) => { const intent = formData.get('intent'); - const submission = parseWithZod(formData, { schema: updateAccountSchema }); + const submission = parseWithZodTranslatedErrors(formData, { + schema: updateAccountSchema, + errorTranslations, + }); if (submission.status !== 'success') return prevState; @@ -74,7 +81,10 @@ export function UpdateAccountForm({ shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { - return parseWithZod(formData, { schema: updateAccountSchema }); + return parseWithZodTranslatedErrors(formData, { + schema: updateAccountSchema, + errorTranslations, + }); }, }); diff --git a/core/vibes/soul/sections/address-list-section/index.tsx b/core/vibes/soul/sections/address-list-section/index.tsx index a16189e61..0f4cc92a6 100644 --- a/core/vibes/soul/sections/address-list-section/index.tsx +++ b/core/vibes/soul/sections/address-list-section/index.tsx @@ -2,6 +2,7 @@ import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; import { getZodConstraint, parseWithZod } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { ComponentProps, ReactNode, @@ -21,7 +22,7 @@ import { Button } from '@/vibes/soul/primitives/button'; import { Spinner } from '@/vibes/soul/primitives/spinner'; import { toast } from '@/vibes/soul/primitives/toaster'; -import { schema } from './schema'; +import { addressFormErrorTranslations, schema } from './schema'; export type Address = z.infer; @@ -96,6 +97,8 @@ export function AddressListSection({ setDefaultLabel = 'Set as default', emptyStateTitle = "You don't have any addresses", }: AddressListSectionProps) { + const t = useTranslations('Account.Addresses'); + const errorTranslations = addressFormErrorTranslations(t); const actionWithFields = addressAction.bind(null, fields); const [state, formAction] = useActionState(actionWithFields, { @@ -194,6 +197,7 @@ export function AddressListSection({ }} buttonSize="small" cancelLabel={cancelLabel} + errorTranslations={errorTranslations} fields={fields.map((field) => { if ('name' in field && field.name === 'id') { return { @@ -253,6 +257,7 @@ export function AddressListSection({ }} buttonSize="small" cancelLabel={cancelLabel} + errorTranslations={errorTranslations} fields={addressFields} onCancel={() => setActiveAddressIds((prev) => prev.filter((id) => id !== address.id)) diff --git a/core/vibes/soul/sections/address-list-section/schema.ts b/core/vibes/soul/sections/address-list-section/schema.ts index 906e2a8ae..eef829ef9 100644 --- a/core/vibes/soul/sections/address-list-section/schema.ts +++ b/core/vibes/soul/sections/address-list-section/schema.ts @@ -1,5 +1,35 @@ +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; +import { FormErrorTranslationMap } from '@/vibes/soul/form/dynamic-form/schema'; +import { ExistingResultType } from '~/client/util'; + +export const addressFormErrorTranslations = ( + t: ExistingResultType>, +): FormErrorTranslationMap => ({ + firstName: { + invalid_type: t('FieldErrors.firstNameRequired'), + }, + lastName: { + invalid_type: t('FieldErrors.lastNameRequired'), + }, + address1: { + invalid_type: t('FieldErrors.addressLine1Required'), + }, + city: { + invalid_type: t('FieldErrors.cityRequired'), + }, + countryCode: { + invalid_type: t('FieldErrors.countryRequired'), + }, + stateOrProvince: { + invalid_type: t('FieldErrors.stateRequired'), + }, + postalCode: { + invalid_type: t('FieldErrors.postalCodeRequired'), + }, +}); + export const schema = z .object({ id: z.string(), diff --git a/core/vibes/soul/sections/cart/coupon-code-form/index.tsx b/core/vibes/soul/sections/cart/coupon-code-form/index.tsx index 8b1662577..f00d7f066 100644 --- a/core/vibes/soul/sections/cart/coupon-code-form/index.tsx +++ b/core/vibes/soul/sections/cart/coupon-code-form/index.tsx @@ -2,6 +2,7 @@ import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { startTransition, useActionState, useOptimistic } from 'react'; import { useFormStatus } from 'react-dom'; @@ -28,7 +29,6 @@ export interface CouponCodeFormProps { label?: string; placeholder?: string; removeLabel?: string; - requiredErrorMessage?: string; } export function CouponCodeForm({ @@ -39,8 +39,9 @@ export function CouponCodeForm({ label = 'Promo code', placeholder, removeLabel, - requiredErrorMessage, }: CouponCodeFormProps) { + const t = useTranslations('Cart.CheckoutSummary.CouponCode'); + const schema = couponCodeActionFormDataSchema({ required_error: t('invalidCouponCode') }); const [state, formAction] = useActionState(action, { couponCodes: couponCodes ?? [], lastResult: null, @@ -49,9 +50,7 @@ export function CouponCodeForm({ const [optimisticCouponCodes, setOptimisticCouponCodes] = useOptimistic( state.couponCodes, (prevState, formData) => { - const submission = parseWithZod(formData, { - schema: couponCodeActionFormDataSchema({ required_error: requiredErrorMessage }), - }); + const submission = parseWithZod(formData, { schema }); if (submission.status !== 'success') return prevState; @@ -73,9 +72,7 @@ export function CouponCodeForm({ shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { - return parseWithZod(formData, { - schema: couponCodeActionFormDataSchema({ required_error: requiredErrorMessage }), - }); + return parseWithZod(formData, { schema }); }, onSubmit(event, { formData }) { event.preventDefault(); diff --git a/core/vibes/soul/sections/cart/gift-certificate-code-form/index.tsx b/core/vibes/soul/sections/cart/gift-certificate-code-form/index.tsx index 8c39d9107..bbbadffe0 100644 --- a/core/vibes/soul/sections/cart/gift-certificate-code-form/index.tsx +++ b/core/vibes/soul/sections/cart/gift-certificate-code-form/index.tsx @@ -2,6 +2,7 @@ import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { startTransition, useActionState, useOptimistic } from 'react'; import { useFormStatus } from 'react-dom'; @@ -28,7 +29,6 @@ export interface GiftCertificateCodeFormProps { label?: string; placeholder?: string; removeLabel?: string; - requiredErrorMessage?: string; } export function GiftCertificateCodeForm({ @@ -39,14 +39,16 @@ export function GiftCertificateCodeForm({ label = 'Gift certificate code', placeholder, removeLabel, - requiredErrorMessage, }: GiftCertificateCodeFormProps) { + const t = useTranslations('Cart.GiftCertificate'); const [state, formAction] = useActionState(action, { giftCertificateCodes: giftCertificateCodes ?? [], lastResult: null, }); - const schema = giftCertificateCodeActionFormDataSchema({ required_error: requiredErrorMessage }); + const schema = giftCertificateCodeActionFormDataSchema({ + required_error: t('invalidGiftCertificate'), + }); const [optimisticGiftCertificateCodes, setOptimisticGiftCertificateCodes] = useOptimistic< string[], diff --git a/core/vibes/soul/sections/cart/schema.ts b/core/vibes/soul/sections/cart/schema.ts index e67a52502..50d35712e 100644 --- a/core/vibes/soul/sections/cart/schema.ts +++ b/core/vibes/soul/sections/cart/schema.ts @@ -47,16 +47,21 @@ export const giftCertificateCodeActionFormDataSchema = ({ }), ]); -export const shippingActionFormDataSchema = z.discriminatedUnion('intent', [ - z.object({ - intent: z.literal('add-address'), - country: z.string(), - city: z.string().optional(), - state: z.string().optional(), - postalCode: z.string().optional(), - }), - z.object({ - intent: z.literal('add-shipping'), - shippingOption: z.string(), - }), -]); +export const shippingActionFormDataSchema = ({ + required_error = 'Country is required', +}: { + required_error?: string; +}) => + z.discriminatedUnion('intent', [ + z.object({ + intent: z.literal('add-address'), + country: z.string({ required_error }), + city: z.string().optional(), + state: z.string().optional(), + postalCode: z.string().optional(), + }), + z.object({ + intent: z.literal('add-shipping'), + shippingOption: z.string(), + }), + ]); diff --git a/core/vibes/soul/sections/cart/shipping-form/index.tsx b/core/vibes/soul/sections/cart/shipping-form/index.tsx index 5fc786474..554f92d4d 100644 --- a/core/vibes/soul/sections/cart/shipping-form/index.tsx +++ b/core/vibes/soul/sections/cart/shipping-form/index.tsx @@ -9,6 +9,7 @@ import { } from '@conform-to/react'; import { getZodConstraint, parseWithZod } from '@conform-to/zod'; import { clsx } from 'clsx'; +import { useTranslations } from 'next-intl'; import { startTransition, useActionState, useEffect, useMemo, useState } from 'react'; import { useFormStatus } from 'react-dom'; @@ -106,6 +107,8 @@ export function ShippingForm({ showShippingForm = false, noShippingOptionsLabel = 'There are no shipping options available for your address', }: Props) { + const t = useTranslations('Cart.CheckoutSummary.Shipping'); + const schema = shippingActionFormDataSchema({ required_error: t('countryRequired') }); const [showForms, setShowForms] = useState(showShippingForm); const [showAddressForm, setShowAddressForm] = useState(!address); @@ -119,7 +122,7 @@ export function ShippingForm({ const [addressForm, addressFields] = useForm({ lastResult: state.form === 'address' ? state.lastResult : null, - constraint: getZodConstraint(shippingActionFormDataSchema), + constraint: getZodConstraint(schema), shouldValidate: 'onBlur', shouldRevalidate: 'onInput', defaultValue: { @@ -129,7 +132,7 @@ export function ShippingForm({ postalCode: state.address?.postalCode, }, onValidate({ formData }) { - return parseWithZod(formData, { schema: shippingActionFormDataSchema }); + return parseWithZod(formData, { schema }); }, onSubmit(event, { formData }) { event.preventDefault(); @@ -143,14 +146,14 @@ export function ShippingForm({ const [shippingOptionsForm, shippingOptionsFields] = useForm({ lastResult: state.form === 'shipping' ? state.lastResult : null, - constraint: getZodConstraint(shippingActionFormDataSchema), + constraint: getZodConstraint(schema), shouldValidate: 'onBlur', shouldRevalidate: 'onInput', defaultValue: { shippingOption: state.shippingOption?.value, }, onValidate({ formData }) { - return parseWithZod(formData, { schema: shippingActionFormDataSchema }); + return parseWithZod(formData, { schema }); }, onSubmit(event, { formData }) { event.preventDefault(); diff --git a/core/vibes/soul/sections/dynamic-form-section/index.tsx b/core/vibes/soul/sections/dynamic-form-section/index.tsx index 2a0aa8d77..3de3df6b3 100644 --- a/core/vibes/soul/sections/dynamic-form-section/index.tsx +++ b/core/vibes/soul/sections/dynamic-form-section/index.tsx @@ -4,6 +4,7 @@ import { DynamicForm, DynamicFormAction } from '@/vibes/soul/form/dynamic-form'; import { Field, FieldGroup, + FormErrorTranslationMap, PasswordComplexitySettings, } from '@/vibes/soul/form/dynamic-form/schema'; import { SectionLayout } from '@/vibes/soul/sections/section-layout'; @@ -16,6 +17,7 @@ interface Props { submitLabel?: string; className?: string; passwordComplexity?: PasswordComplexitySettings | null; + errorTranslations?: FormErrorTranslationMap; } export function DynamicFormSection({ @@ -26,6 +28,7 @@ export function DynamicFormSection({ submitLabel, action, passwordComplexity, + errorTranslations, }: Props) { return ( @@ -41,6 +44,7 @@ export function DynamicFormSection({ )} = (state: Awaited, payload: Payload) => State | Promise; @@ -29,6 +31,8 @@ export function ForgotPasswordForm({ emailLabel = 'Email', submitLabel = 'Reset password', }: Props) { + const t = useTranslations('Auth.Login.ForgotPassword'); + const errorTranslations = forgotPasswordErrorTranslations(t); const [{ lastResult, successMessage }, formAction] = useActionState(action, { lastResult: null }); const [form, fields] = useForm({ lastResult, @@ -36,7 +40,7 @@ export function ForgotPasswordForm({ shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseWithZodTranslatedErrors(formData, { schema, errorTranslations }); }, }); diff --git a/core/vibes/soul/sections/forgot-password-section/schema.ts b/core/vibes/soul/sections/forgot-password-section/schema.ts index c777b6589..d685b379b 100644 --- a/core/vibes/soul/sections/forgot-password-section/schema.ts +++ b/core/vibes/soul/sections/forgot-password-section/schema.ts @@ -1,5 +1,18 @@ +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; +import { FormErrorTranslationMap } from '@/vibes/soul/form/dynamic-form/schema'; +import { ExistingResultType } from '~/client/util'; + +export const forgotPasswordErrorTranslations = ( + t: ExistingResultType>, +): FormErrorTranslationMap => ({ + email: { + invalid_type: t('FieldErrors.emailRequired'), + invalid_string: t('FieldErrors.emailInvalid'), + }, +}); + export const schema = z.object({ - email: z.string().email({ message: 'Please enter a valid email.' }).trim(), + email: z.string().email().trim(), }); diff --git a/core/vibes/soul/sections/gift-certificate-purchase-section/index.tsx b/core/vibes/soul/sections/gift-certificate-purchase-section/index.tsx index 71a610415..8617d5583 100644 --- a/core/vibes/soul/sections/gift-certificate-purchase-section/index.tsx +++ b/core/vibes/soul/sections/gift-certificate-purchase-section/index.tsx @@ -2,11 +2,11 @@ import { SubmissionResult } from '@conform-to/react'; import { clsx } from 'clsx'; -import { useFormatter } from 'next-intl'; +import { useFormatter, useTranslations } from 'next-intl'; import { ReactNode, useCallback, useState } from 'react'; import { DynamicForm, DynamicFormAction } from '@/vibes/soul/form/dynamic-form'; -import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; +import { Field, FieldGroup, FormErrorTranslationMap } from '@/vibes/soul/form/dynamic-form/schema'; import { Streamable } from '@/vibes/soul/lib/streamable'; import { GiftCertificateCard } from '@/vibes/soul/primitives/gift-certificate-card'; import { toast } from '@/vibes/soul/primitives/toaster'; @@ -58,8 +58,36 @@ export function GiftCertificatePurchaseSection({ expiresAtLabel, ctaLabel = 'Add to cart', }: Props) { + const t = useTranslations('GiftCertificates.Purchase'); const format = useFormatter(); const [formattedAmount, setFormattedAmount] = useState(undefined); + const errorTranslations: FormErrorTranslationMap = { + amount: { + invalid_type: t('Form.Errors.amountRequired'), + invalid_string: t('Form.Errors.amountInvalid'), + }, + senderName: { + invalid_type: t('Form.Errors.senderNameRequired'), + }, + senderEmail: { + invalid_type: t('Form.Errors.senderEmailRequired'), + invalid_string: t('Form.Errors.emailInvalid'), + }, + recipientName: { + invalid_type: t('Form.Errors.recipientNameRequired'), + }, + recipientEmail: { + invalid_type: t('Form.Errors.recipientEmailRequired'), + invalid_string: t('Form.Errors.emailInvalid'), + }, + nonRefundable: { + invalid_literal: t('Form.Errors.checkboxRequired'), + }, + expirationConsent: { + invalid_literal: t('Form.Errors.checkboxRequired'), + }, + }; + const handleFormChange = (e: React.FormEvent) => { if (!(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement)) { return; @@ -156,6 +184,7 @@ export function GiftCertificatePurchaseSection({ diff --git a/core/vibes/soul/sections/reset-password-section/reset-password-form.tsx b/core/vibes/soul/sections/reset-password-section/reset-password-form.tsx index 5e553b82c..0557e1327 100644 --- a/core/vibes/soul/sections/reset-password-section/reset-password-form.tsx +++ b/core/vibes/soul/sections/reset-password-section/reset-password-form.tsx @@ -1,14 +1,17 @@ 'use client'; import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; -import { getZodConstraint, parseWithZod } from '@conform-to/zod'; +import { getZodConstraint } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { useActionState } from 'react'; +import { PasswordComplexitySettings } from '@/vibes/soul/form/dynamic-form/schema'; import { FormStatus } from '@/vibes/soul/form/form-status'; import { Input } from '@/vibes/soul/form/input'; import { Button } from '@/vibes/soul/primitives/button'; +import { parseWithZodTranslatedErrors } from '~/i18n/utils'; -import { schema } from './schema'; +import { resetPasswordErrorTranslations, resetPasswordSchema } from './schema'; type Action = (state: Awaited, payload: Payload) => State | Promise; @@ -22,6 +25,7 @@ interface Props { submitLabel?: string; newPasswordLabel?: string; confirmPasswordLabel?: string; + passwordComplexitySettings?: PasswordComplexitySettings | null; } export function ResetPasswordForm({ @@ -29,7 +33,11 @@ export function ResetPasswordForm({ newPasswordLabel = 'New password', confirmPasswordLabel = 'Confirm Password', submitLabel = 'Update', + passwordComplexitySettings, }: Props) { + const t = useTranslations('Auth.ChangePassword'); + const errorTranslations = resetPasswordErrorTranslations(t, passwordComplexitySettings); + const schema = resetPasswordSchema(passwordComplexitySettings, errorTranslations); const [{ lastResult, successMessage }, formAction, isPending] = useActionState(action, { lastResult: null, }); @@ -39,7 +47,7 @@ export function ResetPasswordForm({ shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseWithZodTranslatedErrors(formData, { schema, errorTranslations }); }, }); diff --git a/core/vibes/soul/sections/reset-password-section/schema.ts b/core/vibes/soul/sections/reset-password-section/schema.ts index 441f15484..4146bfd34 100644 --- a/core/vibes/soul/sections/reset-password-section/schema.ts +++ b/core/vibes/soul/sections/reset-password-section/schema.ts @@ -1,24 +1,55 @@ +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; -export const schema = z - .object({ - password: z - .string() - .min(8, { message: 'Be at least 8 characters long' }) - .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' }) - .regex(/[0-9]/, { message: 'Contain at least one number.' }) - .regex(/[^a-zA-Z0-9]/, { - message: 'Contain at least one special character.', - }) - .trim(), - confirmPassword: z.string(), - }) - .superRefine(({ confirmPassword, password }, ctx) => { - if (confirmPassword !== password) { - ctx.addIssue({ - code: 'custom', - message: 'The passwords did not match', - path: ['confirmPassword'], - }); - } - }); +import { + FormErrorTranslationMap, + getPasswordSchema, + PasswordComplexitySettings, +} from '@/vibes/soul/form/dynamic-form/schema'; +import { ExistingResultType } from '~/client/util'; + +export const resetPasswordErrorTranslations = ( + t: ExistingResultType>, + passwordComplexity?: PasswordComplexitySettings | null, +): FormErrorTranslationMap => ({ + password: { + invalid_type: t('FieldErrors.passwordRequired'), + too_small: t('FieldErrors.passwordTooSmall', { + minLength: passwordComplexity?.minimumPasswordLength ?? 0, + }), + lowercase_required: t('FieldErrors.passwordLowercaseRequired'), + uppercase_required: t('FieldErrors.passwordUppercaseRequired'), + number_required: t('FieldErrors.passwordNumberRequired', { + minNumbers: passwordComplexity?.minimumNumbers ?? 1, + }), + special_character_required: t('FieldErrors.passwordSpecialCharacterRequired'), + passwords_must_match: t('FieldErrors.passwordsMustMatch'), + }, + confirmPassword: { + invalid_type: t('FieldErrors.passwordRequired'), + }, +}); + +export const resetPasswordSchema = ( + passwordComplexity?: PasswordComplexitySettings | null, + errorTranslations?: FormErrorTranslationMap, +) => { + const passwordSchema = getPasswordSchema(passwordComplexity, errorTranslations); + + return z + .object({ + currentPassword: z.string().trim(), + password: passwordSchema, + confirmPassword: z.string(), + }) + .superRefine(({ confirmPassword, password }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + code: 'custom', + message: + errorTranslations?.password?.passwords_must_match ?? 'The passwords do not match', + path: ['confirmPassword'], + }); + } + }); +}; diff --git a/core/vibes/soul/sections/reviews/index.tsx b/core/vibes/soul/sections/reviews/index.tsx index a56b61d58..d248f66f2 100644 --- a/core/vibes/soul/sections/reviews/index.tsx +++ b/core/vibes/soul/sections/reviews/index.tsx @@ -28,6 +28,7 @@ interface Props { formButtonLabel?: string; formModalTitle?: string; formSubmitLabel?: string; + formCancelLabel?: string; formRatingLabel?: string; formTitleLabel?: string; formReviewLabel?: string; @@ -52,6 +53,7 @@ export function Reviews({ formButtonLabel = 'Write a review', formModalTitle, formSubmitLabel, + formCancelLabel, formRatingLabel, formTitleLabel, formReviewLabel, @@ -69,6 +71,7 @@ export function Reviews({ {message}

= (state: Awaited, payload: P) => S | Promise; @@ -30,6 +32,7 @@ interface Props { trigger: React.ReactNode; formModalTitle?: string; formSubmitLabel?: string; + formCancelLabel?: string; formRatingLabel?: string; formTitleLabel?: string; formReviewLabel?: string; @@ -46,6 +49,7 @@ export const ReviewForm = ({ trigger, formModalTitle = 'Write a review', formSubmitLabel = 'Submit', + formCancelLabel = 'Cancel', formRatingLabel = 'Rating', formTitleLabel = 'Title', formReviewLabel = 'Review', @@ -55,6 +59,8 @@ export const ReviewForm = ({ streamableImages, streamableUser, }: Props) => { + const t = useTranslations('Product.Reviews.Form'); + const errorTranslations = reviewFormErrorTranslations(t); const [isOpen, setIsOpen] = useState(false); const [{ lastResult, successMessage }, formAction] = useActionState(action, { lastResult: null, @@ -73,7 +79,7 @@ export const ReviewForm = ({ author: user.name, }, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseWithZodTranslatedErrors(formData, { schema, errorTranslations }); }, onSubmit(event, { formData }) { event.preventDefault(); @@ -213,7 +219,7 @@ export const ReviewForm = ({ ))}
{formSubmitLabel}
diff --git a/core/vibes/soul/sections/reviews/schema.ts b/core/vibes/soul/sections/reviews/schema.ts index 51f422639..75519a6c9 100644 --- a/core/vibes/soul/sections/reviews/schema.ts +++ b/core/vibes/soul/sections/reviews/schema.ts @@ -1,5 +1,32 @@ +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; +import { FormErrorTranslationMap } from '@/vibes/soul/form/dynamic-form/schema'; +import { ExistingResultType } from '~/client/util'; + +export const reviewFormErrorTranslations = ( + t: ExistingResultType>, +): FormErrorTranslationMap => ({ + title: { + invalid_type: t('FieldErrors.titleRequired'), + }, + author: { + invalid_type: t('FieldErrors.authorRequired'), + }, + email: { + invalid_type: t('FieldErrors.emailRequired'), + invalid_string: t('FieldErrors.emailInvalid'), + }, + text: { + invalid_type: t('FieldErrors.textRequired'), + }, + rating: { + invalid_type: t('FieldErrors.ratingRequired'), + too_small: t('FieldErrors.ratingTooSmall'), + too_big: t('FieldErrors.ratingTooLarge'), + }, +}); + export const schema = z.object({ productEntityId: z.number(), title: z.string().min(1), diff --git a/core/vibes/soul/sections/sign-in-section/schema.ts b/core/vibes/soul/sections/sign-in-section/schema.ts index 5a7ed0f12..6e746ff97 100644 --- a/core/vibes/soul/sections/sign-in-section/schema.ts +++ b/core/vibes/soul/sections/sign-in-section/schema.ts @@ -1,5 +1,21 @@ +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; +import { FormErrorTranslationMap } from '@/vibes/soul/form/dynamic-form/schema'; +import { ExistingResultType } from '~/client/util'; + +export const loginErrorTranslations = ( + t: ExistingResultType>, +): FormErrorTranslationMap => ({ + email: { + invalid_type: t('FieldErrors.emailRequired'), + invalid_string: t('FieldErrors.emailInvalid'), + }, + password: { + invalid_type: t('FieldErrors.passwordRequired'), + }, +}); + export const schema = z.object({ email: z.string().email(), password: z.string(), diff --git a/core/vibes/soul/sections/sign-in-section/sign-in-form.tsx b/core/vibes/soul/sections/sign-in-section/sign-in-form.tsx index 9c0a90283..26c31ae73 100644 --- a/core/vibes/soul/sections/sign-in-section/sign-in-form.tsx +++ b/core/vibes/soul/sections/sign-in-section/sign-in-form.tsx @@ -1,15 +1,17 @@ 'use client'; import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; -import { getZodConstraint, parseWithZod } from '@conform-to/zod'; +import { getZodConstraint } from '@conform-to/zod'; +import { useTranslations } from 'next-intl'; import { startTransition, useActionState, useEffect } from 'react'; import { useFormStatus } from 'react-dom'; import { FormStatus } from '@/vibes/soul/form/form-status'; import { Input } from '@/vibes/soul/form/input'; import { Button } from '@/vibes/soul/primitives/button'; +import { parseWithZodTranslatedErrors } from '~/i18n/utils'; -import { schema } from './schema'; +import { loginErrorTranslations, schema } from './schema'; type Action = (state: Awaited, payload: Payload) => State | Promise; @@ -30,6 +32,8 @@ export function SignInForm({ submitLabel = 'Sign in', error, }: Props) { + const t = useTranslations('Auth.Login'); + const errorTranslations = loginErrorTranslations(t); const [lastResult, formAction] = useActionState(action, null); const [form, fields] = useForm({ lastResult, @@ -44,7 +48,7 @@ export function SignInForm({ }); }, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseWithZodTranslatedErrors(formData, { schema, errorTranslations }); }, }); From 06336122585db5021de9028ed88e2bc48c6faede Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 3 Feb 2026 12:50:07 -0600 Subject: [PATCH 06/17] fix(core): use state abbreviation instead of entityId for shipping form (#2858) --- .changeset/legal-adults-look.md | 53 +++++++++++++++++++++++ core/app/[locale]/(default)/cart/page.tsx | 19 ++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 .changeset/legal-adults-look.md diff --git a/.changeset/legal-adults-look.md b/.changeset/legal-adults-look.md new file mode 100644 index 000000000..27d8b1d0c --- /dev/null +++ b/.changeset/legal-adults-look.md @@ -0,0 +1,53 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Use state abbreviation instead of entityId for cart shipping form state values. The shipping API expects state abbreviations, and using entityId caused form submissions to fail. Additionally, certain US military states that share the same abbreviation (AE) are now filtered out to prevent duplicate key issues and ambiguous submissions. + +## Migration steps + +### Step 1: Add blacklist for states with duplicate abbreviations + +Certain US states share the same abbreviation (AE), which causes issues with the shipping API and React select dropdowns. Add a blacklist to filter these out. + +Update `core/app/[locale]/(default)/cart/page.tsx`: + +```diff + const countries = shippingCountries.map((country) => ({ + value: country.code, + label: country.name, + })); + ++ // These US states share the same abbreviation (AE), which causes issues: ++ // 1. The shipping API uses abbreviations, so it can't distinguish between them ++ // 2. React select dropdowns require unique keys, causing duplicate key warnings ++ const blacklistedUSStates = new Set([ ++ 'Armed Forces Africa', ++ 'Armed Forces Canada', ++ 'Armed Forces Middle East', ++ ]); + + const statesOrProvinces = shippingCountries.map((country) => ({ +``` + +### Step 2: Use state abbreviation instead of entityId + +Update the state mapping to use `abbreviation` instead of `entityId`, and apply the blacklist filter for US states. + +Update `core/app/[locale]/(default)/cart/page.tsx`: + +```diff + const statesOrProvinces = shippingCountries.map((country) => ({ + country: country.code, +- states: country.statesOrProvinces.map((state) => ({ +- value: state.entityId.toString(), +- label: state.name, +- })), ++ states: country.statesOrProvinces ++ .filter((state) => country.code !== 'US' || !blacklistedUSStates.has(state.name)) ++ .map((state) => ({ ++ value: state.abbreviation, ++ label: state.name, ++ })), + })); +``` diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index 677f74523..f78ad9076 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -192,12 +192,23 @@ export default async function Cart({ params }: Props) { label: country.name, })); + // These US states share the same abbreviation (AE), which causes issues: + // 1. The shipping API uses abbreviations, so it can't distinguish between them + // 2. React select dropdowns require unique keys, causing duplicate key warnings + const blacklistedUSStates = new Set([ + 'Armed Forces Africa', + 'Armed Forces Canada', + 'Armed Forces Middle East', + ]); + const statesOrProvinces = shippingCountries.map((country) => ({ country: country.code, - states: country.statesOrProvinces.map((state) => ({ - value: state.entityId.toString(), - label: state.name, - })), + states: country.statesOrProvinces + .filter((state) => country.code !== 'US' || !blacklistedUSStates.has(state.name)) + .map((state) => ({ + value: state.abbreviation, + label: state.name, + })), })); const showShippingForm = From 6d0d52b9c7fe2adceb1fab03add7286eb4024a36 Mon Sep 17 00:00:00 2001 From: James Q Quick Date: Wed, 4 Feb 2026 10:08:07 -0500 Subject: [PATCH 07/17] [CATALYST-1686] Clarify REST Management API in Contributing (#2861) * Update contributing doc to clarify not accepting PRs to integrate REST Management API * update broken links and text --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d91bd6bd..29c9e04c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,12 @@ The default branch for this repository is called `canary`. This is the primary d To contribute to the `canary` branch, you can create a new branch off of `canary` and submit a PR against that branch. +## API Scope + +Catalyst is intended to work with the [BigCommerce Storefront GraphQL API](https://developer.bigcommerce.com/docs/storefront/graphql) and not directly integrate out of the box with the [REST Management API](https://developer.bigcommerce.com/docs/rest-management). + +You're welcome to integrate the REST Management API in your own fork, but we will not accept pull requests that incorporate or depend on the REST Management API. If your contribution requires Management API functionality, it is out of scope for this project. + ## Makeswift Integration In addition to `canary`, we also maintain the `integrations/makeswift` branch, which contains additional code required to integrate with [Makeswift](https://www.makeswift.com). From 254dbc519e93a57ec5568776e6f34e3e6638eb92 Mon Sep 17 00:00:00 2001 From: James Q Quick Date: Fri, 6 Feb 2026 15:10:12 -0500 Subject: [PATCH 08/17] [CATALYST-1743] - Create Project Commands (#2865) * create project command with three nested commands (create, link, and list) * updated error message in build command * fix linting * Update packages/catalyst/src/cli/commands/build.ts Co-authored-by: Chancellor Clark * merge link tests into project * organize tests for project using describe * fix linting --------- Co-authored-by: Chancellor Clark --- packages/catalyst/src/cli/commands/build.ts | 2 +- packages/catalyst/src/cli/commands/deploy.ts | 2 +- .../catalyst/src/cli/commands/link.spec.ts | 318 ------------ packages/catalyst/src/cli/commands/link.ts | 208 -------- .../catalyst/src/cli/commands/project.spec.ts | 476 ++++++++++++++++++ packages/catalyst/src/cli/commands/project.ts | 252 ++++++++++ packages/catalyst/src/cli/index.spec.ts | 8 +- packages/catalyst/src/cli/lib/project.ts | 103 ++++ packages/catalyst/src/cli/program.ts | 4 +- 9 files changed, 842 insertions(+), 531 deletions(-) delete mode 100644 packages/catalyst/src/cli/commands/link.spec.ts delete mode 100644 packages/catalyst/src/cli/commands/link.ts create mode 100644 packages/catalyst/src/cli/commands/project.spec.ts create mode 100644 packages/catalyst/src/cli/commands/project.ts create mode 100644 packages/catalyst/src/cli/lib/project.ts diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 86e3be529..94898ba88 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -60,7 +60,7 @@ export const build = new Command('build') if (!projectUuid) { throw new Error( - 'Project UUID is required. Please run `link` or provide `--project-uuid`', + 'Project UUID is required. Please run `catalyst project create` or `catalyst project link` or this command again with --project-uuid .', ); } diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index f1a40ae5d..eee50458f 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -324,7 +324,7 @@ export const deploy = new Command('deploy') if (!projectUuid) { throw new Error( - 'Project UUID is required. Please run either `bigcommerce link` or this command again with --project-uuid .', + 'Project UUID is required. Please run either `catalyst project link` or `catalyst project create` or this command again with --project-uuid .', ); } diff --git a/packages/catalyst/src/cli/commands/link.spec.ts b/packages/catalyst/src/cli/commands/link.spec.ts deleted file mode 100644 index b031df569..000000000 --- a/packages/catalyst/src/cli/commands/link.spec.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Command } from 'commander'; -import Conf from 'conf'; -import { http, HttpResponse } from 'msw'; -import { afterAll, afterEach, beforeAll, expect, MockInstance, test, vi } from 'vitest'; - -import { server } from '../../../tests/mocks/node'; -import { consola } from '../lib/logger'; -import { mkTempDir } from '../lib/mk-temp-dir'; -import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; -import { program } from '../program'; - -import { link } from './link'; - -let exitMock: MockInstance; - -let tmpDir: string; -let cleanup: () => Promise; -let config: Conf; - -const { mockIdentify } = vi.hoisted(() => ({ - mockIdentify: vi.fn(), -})); - -const projectUuid1 = 'a23f5785-fd99-4a94-9fb3-945551623923'; -const projectUuid2 = 'b23f5785-fd99-4a94-9fb3-945551623924'; -const projectUuid3 = 'c23f5785-fd99-4a94-9fb3-945551623925'; -const storeHash = 'test-store'; -const accessToken = 'test-token'; - -beforeAll(async () => { - consola.mockTypes(() => vi.fn()); - - vi.mock('../lib/telemetry', () => { - return { - Telemetry: vi.fn().mockImplementation(() => { - return { - identify: mockIdentify, - isEnabled: vi.fn(() => true), - track: vi.fn(), - analytics: { - closeAndFlush: vi.fn(), - }, - }; - }), - }; - }); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); - - [tmpDir, cleanup] = await mkTempDir(); - - config = getProjectConfig(tmpDir); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -afterAll(async () => { - vi.restoreAllMocks(); - exitMock.mockRestore(); - - await cleanup(); -}); - -test('properly configured Command instance', () => { - expect(link).toBeInstanceOf(Command); - expect(link.name()).toBe('link'); - expect(link.description()).toBe( - 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', - ); - expect(link.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ flags: '--store-hash ' }), - expect.objectContaining({ flags: '--access-token ' }), - expect.objectContaining({ flags: '--api-host ', defaultValue: 'api.bigcommerce.com' }), - expect.objectContaining({ flags: '--project-uuid ' }), - expect.objectContaining({ flags: '--root-dir ', defaultValue: process.cwd() }), - ]), - ); -}); - -test('sets projectUuid when called with --project-uuid', async () => { - await program.parseAsync([ - 'node', - 'catalyst', - 'link', - '--project-uuid', - projectUuid1, - '--root-dir', - tmpDir, - ]); - - expect(consola.start).toHaveBeenCalledWith( - 'Writing project UUID to .bigcommerce/project.json...', - ); - expect(consola.success).toHaveBeenCalledWith( - 'Project UUID written to .bigcommerce/project.json.', - ); - expect(exitMock).toHaveBeenCalledWith(0); - expect(config.get('projectUuid')).toBe(projectUuid1); - expect(config.get('framework')).toBe('catalyst'); -}); - -test('fetches projects and prompts user to select one', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async (message, opts) => { - // Assert the prompt message and options - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - // Simulate selecting the second option - return new Promise((resolve) => resolve(projectUuid2)); - }); - - await program.parseAsync([ - 'node', - 'catalyst', - 'link', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); - - expect(mockIdentify).toHaveBeenCalledWith(storeHash); - - expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); - expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - expect(consola.start).toHaveBeenCalledWith( - 'Writing project UUID to .bigcommerce/project.json...', - ); - expect(consola.success).toHaveBeenCalledWith( - 'Project UUID written to .bigcommerce/project.json.', - ); - - expect(exitMock).toHaveBeenCalledWith(0); - - expect(config.get('projectUuid')).toBe(projectUuid2); - expect(config.get('framework')).toBe('catalyst'); - - consolaPromptMock.mockRestore(); -}); - -test('prompts to create a new project', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - // Assert the prompt message and options - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - // Simulate selecting the create option - return new Promise((resolve) => resolve('create')); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return new Promise((resolve) => resolve('New Project')); - }); - - await program.parseAsync([ - 'node', - 'catalyst', - 'link', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); - - expect(mockIdentify).toHaveBeenCalledWith(storeHash); - - expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); - expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); - - expect(exitMock).toHaveBeenCalledWith(0); - - expect(config.get('projectUuid')).toBe(projectUuid3); - expect(config.get('framework')).toBe('catalyst'); - - consolaPromptMock.mockRestore(); -}); - -test('prompts to create a new project', async () => { - server.use( - http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => - HttpResponse.json({}, { status: 502 }), - ), - ); - - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - // Assert the prompt message and options - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - // Simulate selecting the create option - return new Promise((resolve) => resolve('create')); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return new Promise((resolve) => resolve('New Project')); - }); - - await program.parseAsync([ - 'node', - 'catalyst', - 'link', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); - - expect(mockIdentify).toHaveBeenCalledWith(storeHash); - - expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); - expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - expect(consola.error).toHaveBeenCalledWith( - 'Failed to create project, is the name already in use?', - ); - - expect(exitMock).toHaveBeenCalledWith(1); - - consolaPromptMock.mockRestore(); -}); - -test('errors when infrastructure projects API is not found', async () => { - server.use( - http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => - HttpResponse.json({}, { status: 403 }), - ), - ); - - await program.parseAsync([ - 'node', - 'catalyst', - 'link', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); - - expect(mockIdentify).toHaveBeenCalledWith(storeHash); - - expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); - expect(consola.error).toHaveBeenCalledWith( - 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', - ); -}); - -test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { - await program.parseAsync(['node', 'catalyst', 'link', '--root-dir', tmpDir]); - - expect(consola.start).not.toHaveBeenCalled(); - expect(consola.success).not.toHaveBeenCalled(); - expect(consola.error).toHaveBeenCalledWith('Insufficient information to link a project.'); - expect(consola.info).toHaveBeenCalledWith('Provide a project UUID with --project-uuid, or'); - expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token to fetch and select a project.', - ); - - expect(exitMock).toHaveBeenCalledWith(1); -}); diff --git a/packages/catalyst/src/cli/commands/link.ts b/packages/catalyst/src/cli/commands/link.ts deleted file mode 100644 index b2cca62ed..000000000 --- a/packages/catalyst/src/cli/commands/link.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Command, Option } from 'commander'; -import { z } from 'zod'; - -import { consola } from '../lib/logger'; -import { getProjectConfig } from '../lib/project-config'; -import { Telemetry } from '../lib/telemetry'; - -const telemetry = new Telemetry(); - -const fetchProjectsSchema = z.object({ - data: z.array( - z.object({ - uuid: z.string(), - name: z.string(), - }), - ), -}); - -const createProjectSchema = z.object({ - data: z.object({ - uuid: z.string(), - name: z.string(), - date_created: z.coerce.date(), - date_modified: z.coerce.date(), - }), -}); - -async function fetchProjects(storeHash: string, accessToken: string, apiHost: string) { - const response = await fetch( - `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, - { - method: 'GET', - headers: { - 'X-Auth-Token': accessToken, - }, - }, - ); - - if (response.status === 403) { - throw new Error( - 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', - ); - } - - if (!response.ok) { - throw new Error(`Failed to fetch projects: ${response.statusText}`); - } - - const res: unknown = await response.json(); - - const { data } = fetchProjectsSchema.parse(res); - - return data; -} - -async function createProject( - name: string, - storeHash: string, - accessToken: string, - apiHost: string, -) { - const response = await fetch( - `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, - { - method: 'POST', - headers: { - 'X-Auth-Token': accessToken, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }, - ); - - if (response.status === 502) { - throw new Error('Failed to create project, is the name already in use?'); - } - - if (response.status === 403) { - throw new Error( - 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', - ); - } - - if (!response.ok) { - throw new Error(`Failed to create project: ${response.statusText}`); - } - - const res: unknown = await response.json(); - - const { data } = createProjectSchema.parse(res); - - return data; -} - -export const link = new Command('link') - .description( - 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', - ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('BIGCOMMERCE_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), - ) - .option( - '--project-uuid ', - 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects.', - ) - .option( - '--root-dir ', - 'Path to the root directory of your Catalyst project (default: current working directory).', - process.cwd(), - ) - .action(async (options) => { - try { - const config = getProjectConfig(options.rootDir); - - const writeProjectConfig = (uuid: string) => { - consola.start('Writing project UUID to .bigcommerce/project.json...'); - config.set('projectUuid', uuid); - config.set('framework', 'catalyst'); - consola.success('Project UUID written to .bigcommerce/project.json.'); - }; - - if (options.projectUuid) { - writeProjectConfig(options.projectUuid); - - process.exit(0); - } - - if (options.storeHash && options.accessToken) { - await telemetry.identify(options.storeHash); - - consola.start('Fetching projects...'); - - const projects = await fetchProjects( - options.storeHash, - options.accessToken, - options.apiHost, - ); - - consola.success('Projects fetched.'); - - const promptOptions = [ - ...projects.map((project) => ({ - label: project.name, - value: project.uuid, - hint: project.uuid, - })), - { - label: 'Create a new project', - value: 'create', - hint: 'Create a new infrastructure project for this BigCommerce store.', - }, - ]; - - let projectUuid = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { - type: 'select', - options: promptOptions, - cancel: 'reject', - }, - ); - - if (projectUuid === 'create') { - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); - - const data = await createProject( - newProjectName, - options.storeHash, - options.accessToken, - options.apiHost, - ); - - projectUuid = data.uuid; - - consola.success(`Project "${data.name}" created successfully.`); - } - - writeProjectConfig(projectUuid); - - process.exit(0); - } - - consola.error('Insufficient information to link a project.'); - consola.info('Provide a project UUID with --project-uuid, or'); - consola.info('Provide both --store-hash and --access-token to fetch and select a project.'); - process.exit(1); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); - } - }); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts new file mode 100644 index 000000000..68a32cab5 --- /dev/null +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -0,0 +1,476 @@ +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { program } from '../program'; + +import { link, project } from './project'; + +let exitMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +const projectUuid1 = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const projectUuid2 = 'b23f5785-fd99-4a94-9fb3-945551623924'; +const projectUuid3 = 'c23f5785-fd99-4a94-9fb3-945551623925'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + + vi.mock('../lib/telemetry', () => { + return { + Telemetry: vi.fn().mockImplementation(() => { + return { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + analytics: { + closeAndFlush: vi.fn(), + }, + }; + }), + }; + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + config = getProjectConfig(tmpDir); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('project', () => { + test('has create, link, and list subcommands', () => { + expect(project).toBeInstanceOf(Command); + expect(project.name()).toBe('project'); + expect(project.description()).toBe('Manage your BigCommerce infrastructure project.'); + + const createCmd = project.commands.find((cmd) => cmd.name() === 'create'); + + expect(createCmd).toBeDefined(); + expect(createCmd?.description()).toContain('Create a new BigCommerce infrastructure project'); + + const linkCmd = project.commands.find((cmd) => cmd.name() === 'link'); + + expect(linkCmd).toBeDefined(); + expect(linkCmd?.description()).toContain( + 'Link your local Catalyst project to a BigCommerce infrastructure project', + ); + + const listCmd = project.commands.find((cmd) => cmd.name() === 'list'); + + expect(listCmd).toBeDefined(); + expect(listCmd?.description()).toContain('List BigCommerce infrastructure projects'); + }); +}); + +describe('project create', () => { + test('prompts for name and creates project', async () => { + const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('My New Project'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(consolaPromptMock).toHaveBeenCalledWith( + 'Enter a name for the new project:', + expect.any(Object), + ); + expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); + expect(consola.start).toHaveBeenCalledWith( + 'Writing project UUID to .bigcommerce/project.json...', + ); + expect(consola.success).toHaveBeenCalledWith( + 'Project UUID written to .bigcommerce/project.json.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('projectUuid')).toBe('c23f5785-fd99-4a94-9fb3-945551623925'); + expect(config.get('framework')).toBe('catalyst'); + + consolaPromptMock.mockRestore(); + }); + + test('with insufficient credentials exits with error', async () => { + // Unset env so Commander doesn't pick up BIGCOMMERCE_* and trigger the create flow (which would prompt for name) + const savedStoreHash = process.env.BIGCOMMERCE_STORE_HASH; + const savedAccessToken = process.env.BIGCOMMERCE_ACCESS_TOKEN; + + delete process.env.BIGCOMMERCE_STORE_HASH; + delete process.env.BIGCOMMERCE_ACCESS_TOKEN; + + await program.parseAsync(['node', 'catalyst', 'project', 'create', '--root-dir', tmpDir]); + + if (savedStoreHash !== undefined) process.env.BIGCOMMERCE_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.BIGCOMMERCE_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Insufficient information to create a project.'); + expect(consola.info).toHaveBeenCalledWith( + 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('propagates create project API errors', async () => { + server.use( + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 502 }), + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('Duplicate'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + promptMock.mockRestore(); + + expect(consola.error).toHaveBeenCalledWith( + 'Failed to create project, is the name already in use?', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('project list', () => { + test('fetches and displays projects', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'list', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + expect(consola.log).toHaveBeenCalledWith('Project One (a23f5785-fd99-4a94-9fb3-945551623923)'); + expect(consola.log).toHaveBeenCalledWith('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('with insufficient credentials exits with error', async () => { + const savedStoreHash = process.env.BIGCOMMERCE_STORE_HASH; + const savedAccessToken = process.env.BIGCOMMERCE_ACCESS_TOKEN; + + delete process.env.BIGCOMMERCE_STORE_HASH; + delete process.env.BIGCOMMERCE_ACCESS_TOKEN; + + await program.parseAsync(['node', 'catalyst', 'project', 'list']); + + if (savedStoreHash !== undefined) process.env.BIGCOMMERCE_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.BIGCOMMERCE_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Insufficient information to list projects.'); + expect(consola.info).toHaveBeenCalledWith( + 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('project link', () => { + test('properly configured Command instance', () => { + expect(link).toBeInstanceOf(Command); + expect(link.name()).toBe('link'); + expect(link.description()).toBe( + 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', + ); + expect(link.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ + flags: '--api-host ', + defaultValue: 'api.bigcommerce.com', + }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--root-dir ', defaultValue: process.cwd() }), + ]), + ); + }); + + test('sets projectUuid when called with --project-uuid', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + '--root-dir', + tmpDir, + ]); + + expect(consola.start).toHaveBeenCalledWith( + 'Writing project UUID to .bigcommerce/project.json...', + ); + expect(consola.success).toHaveBeenCalledWith( + 'Project UUID written to .bigcommerce/project.json.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + expect(config.get('projectUuid')).toBe(projectUuid1); + expect(config.get('framework')).toBe('catalyst'); + }); + + test('fetches projects and prompts user to select one', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async (message, opts) => { + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ + label: 'Project Two', + value: projectUuid2, + }); + expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); + + return new Promise((resolve) => resolve(projectUuid2)); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.start).toHaveBeenCalledWith( + 'Writing project UUID to .bigcommerce/project.json...', + ); + expect(consola.success).toHaveBeenCalledWith( + 'Project UUID written to .bigcommerce/project.json.', + ); + + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('projectUuid')).toBe(projectUuid2); + expect(config.get('framework')).toBe('catalyst'); + + consolaPromptMock.mockRestore(); + }); + + test('prompts to create a new project', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ + label: 'Project Two', + value: projectUuid2, + }); + expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); + + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('New Project')); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); + + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('projectUuid')).toBe(projectUuid3); + expect(config.get('framework')).toBe('catalyst'); + + consolaPromptMock.mockRestore(); + }); + + test('errors when create project API fails', async () => { + server.use( + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 502 }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ + label: 'Project Two', + value: projectUuid2, + }); + expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); + + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('New Project')); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.error).toHaveBeenCalledWith( + 'Failed to create project, is the name already in use?', + ); + + expect(exitMock).toHaveBeenCalledWith(1); + + consolaPromptMock.mockRestore(); + }); + + test('errors when infrastructure projects API is not found', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.error).toHaveBeenCalledWith( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + }); + + test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { + await program.parseAsync(['node', 'catalyst', 'project', 'link', '--root-dir', tmpDir]); + + expect(consola.start).not.toHaveBeenCalled(); + expect(consola.success).not.toHaveBeenCalled(); + expect(consola.error).toHaveBeenCalledWith('Insufficient information to link a project.'); + expect(consola.info).toHaveBeenCalledWith('Provide a project UUID with --project-uuid, or'); + expect(consola.info).toHaveBeenCalledWith( + 'Provide both --store-hash and --access-token to fetch and select a project.', + ); + + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts new file mode 100644 index 000000000..a97980e2e --- /dev/null +++ b/packages/catalyst/src/cli/commands/project.ts @@ -0,0 +1,252 @@ +import { Command, Option } from 'commander'; + +import { consola } from '../lib/logger'; +import { createProject, fetchProjects } from '../lib/project'; +import { getProjectConfig } from '../lib/project-config'; +import { Telemetry } from '../lib/telemetry'; + +const telemetry = new Telemetry(); + +const list = new Command('list') + .description('List BigCommerce infrastructure projects for your store.') + .addOption( + new Option( + '--store-hash ', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + ).env('BIGCOMMERCE_STORE_HASH'), + ) + .addOption( + new Option( + '--access-token ', + 'BigCommerce access token. Can be found after creating a store-level API account.', + ).env('BIGCOMMERCE_ACCESS_TOKEN'), + ) + .addOption( + new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') + .env('BIGCOMMERCE_API_HOST') + .default('api.bigcommerce.com'), + ) + .action(async (options) => { + try { + if (!options.storeHash || !options.accessToken) { + consola.error('Insufficient information to list projects.'); + consola.info( + 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + ); + process.exit(1); + + return; + } + + await telemetry.identify(options.storeHash); + + consola.start('Fetching projects...'); + + const projects = await fetchProjects(options.storeHash, options.accessToken, options.apiHost); + + consola.success('Projects fetched.'); + + if (projects.length === 0) { + consola.info('No projects found.'); + process.exit(0); + + return; + } + + projects.forEach((p) => { + consola.log(`${p.name} (${p.uuid})`); + }); + + process.exit(0); + } catch (error) { + consola.error(error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +const create = new Command('create') + .description( + 'Create a new BigCommerce infrastructure project and link it to your local Catalyst project.', + ) + .addOption( + new Option( + '--store-hash ', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + ).env('BIGCOMMERCE_STORE_HASH'), + ) + .addOption( + new Option( + '--access-token ', + 'BigCommerce access token. Can be found after creating a store-level API account.', + ).env('BIGCOMMERCE_ACCESS_TOKEN'), + ) + .addOption( + new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') + .env('BIGCOMMERCE_API_HOST') + .default('api.bigcommerce.com'), + ) + .option( + '--root-dir ', + 'Path to the root directory of your Catalyst project (default: current working directory).', + process.cwd(), + ) + .action(async (options) => { + try { + if (!options.storeHash || !options.accessToken) { + consola.error('Insufficient information to create a project.'); + consola.info( + 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + ); + process.exit(1); + + return; + } + + await telemetry.identify(options.storeHash); + + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', + }); + + const data = await createProject( + newProjectName, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + consola.success(`Project "${data.name}" created successfully.`); + + const config = getProjectConfig(options.rootDir); + + consola.start('Writing project UUID to .bigcommerce/project.json...'); + config.set('projectUuid', data.uuid); + config.set('framework', 'catalyst'); + consola.success('Project UUID written to .bigcommerce/project.json.'); + + process.exit(0); + } catch (error) { + consola.error(error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +export const link = new Command('link') + .description( + 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', + ) + .addOption( + new Option( + '--store-hash ', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + ).env('BIGCOMMERCE_STORE_HASH'), + ) + .addOption( + new Option( + '--access-token ', + 'BigCommerce access token. Can be found after creating a store-level API account.', + ).env('BIGCOMMERCE_ACCESS_TOKEN'), + ) + .addOption( + new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') + .env('BIGCOMMERCE_API_HOST') + .default('api.bigcommerce.com'), + ) + .option( + '--project-uuid ', + 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects.', + ) + .option( + '--root-dir ', + 'Path to the root directory of your Catalyst project (default: current working directory).', + process.cwd(), + ) + .action(async (options) => { + try { + const config = getProjectConfig(options.rootDir); + + const writeProjectConfig = (uuid: string) => { + consola.start('Writing project UUID to .bigcommerce/project.json...'); + config.set('projectUuid', uuid); + config.set('framework', 'catalyst'); + consola.success('Project UUID written to .bigcommerce/project.json.'); + }; + + if (options.projectUuid) { + writeProjectConfig(options.projectUuid); + + process.exit(0); + } + + if (options.storeHash && options.accessToken) { + await telemetry.identify(options.storeHash); + + consola.start('Fetching projects...'); + + const projects = await fetchProjects( + options.storeHash, + options.accessToken, + options.apiHost, + ); + + consola.success('Projects fetched.'); + + const promptOptions = [ + ...projects.map((proj) => ({ + label: proj.name, + value: proj.uuid, + hint: proj.uuid, + })), + { + label: 'Create a new project', + value: 'create', + hint: 'Create a new infrastructure project for this BigCommerce store.', + }, + ]; + + let projectUuid = await consola.prompt( + 'Select a project or create a new project (Press to select).', + { + type: 'select', + options: promptOptions, + cancel: 'reject', + }, + ); + + if (projectUuid === 'create') { + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', + }); + + const data = await createProject( + newProjectName, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + projectUuid = data.uuid; + + consola.success(`Project "${data.name}" created successfully.`); + } + + writeProjectConfig(projectUuid); + + process.exit(0); + } + + consola.error('Insufficient information to link a project.'); + consola.info('Provide a project UUID with --project-uuid, or'); + consola.info('Provide both --store-hash and --access-token to fetch and select a project.'); + process.exit(1); + } catch (error) { + consola.error(error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +export const project = new Command('project') + .description('Manage your BigCommerce infrastructure project.') + .addCommand(create) + .addCommand(list) + .addCommand(link); diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index 5759071df..76f750de9 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -25,7 +25,13 @@ describe('CLI program', () => { expect(commands).toContain('start'); expect(commands).toContain('build'); expect(commands).toContain('deploy'); - expect(commands).toContain('link'); + expect(commands).toContain('project'); + + const projectCmd = program.commands.find((cmd) => cmd.name() === 'project'); + + expect(projectCmd?.commands.map((c) => c.name())).toEqual( + expect.arrayContaining(['create', 'list', 'link']), + ); }); test('telemetry hooks are called when executing version command', async () => { diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts new file mode 100644 index 000000000..ffdb31990 --- /dev/null +++ b/packages/catalyst/src/cli/lib/project.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; + +const fetchProjectsSchema = z.object({ + data: z.array( + z.object({ + uuid: z.string(), + name: z.string(), + }), + ), +}); + +export interface ProjectListItem { + uuid: string; + name: string; +} + +export async function fetchProjects( + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + }, + }, + ); + + if (response.status === 403) { + throw new Error( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + } + + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.statusText}`); + } + + const res: unknown = await response.json(); + + const { data } = fetchProjectsSchema.parse(res); + + return data; +} + +const createProjectSchema = z.object({ + data: z.object({ + uuid: z.string(), + name: z.string(), + date_created: z.coerce.date(), + date_modified: z.coerce.date(), + }), +}); + +export interface CreateProjectResult { + uuid: string; + name: string; + date_created: Date; + date_modified: Date; +} + +export async function createProject( + name: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, + { + method: 'POST', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }, + ); + + if (response.status === 502) { + throw new Error('Failed to create project, is the name already in use?'); + } + + if (response.status === 403) { + throw new Error( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + } + + if (!response.ok) { + throw new Error(`Failed to create project: ${response.statusText}`); + } + + const res: unknown = await response.json(); + + const { data } = createProjectSchema.parse(res); + + return data; +} diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 8f8cd50bc..4a99a17ac 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -8,7 +8,7 @@ import PACKAGE_INFO from '../../package.json'; import { build } from './commands/build'; import { deploy } from './commands/deploy'; import { dev } from './commands/dev'; -import { link } from './commands/link'; +import { project } from './commands/project'; import { start } from './commands/start'; import { telemetry } from './commands/telemetry'; import { version } from './commands/version'; @@ -38,7 +38,7 @@ program .addCommand(start) .addCommand(build) .addCommand(deploy) - .addCommand(link) + .addCommand(project) .addCommand(telemetry) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook); From d78bc85fa4a6ae39d2b99a347a3f9fc56725826a Mon Sep 17 00:00:00 2001 From: Tharaa <36555311+Tharaae@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:46:18 +1100 Subject: [PATCH 09/17] feat: Add items stock/backorder messages to cart page (#2758) * feat: Add items stock/backorder messages to cart page * feat: Add items stock/backorder messages to cart page - Apply UI comments * feat: Add items stock/backorder messages to cart page - Apply review comments --- .changeset/tender-toys-heal.md | 15 ++ core/app/[locale]/(default)/cart/page-data.ts | 13 ++ core/app/[locale]/(default)/cart/page.tsx | 42 +++++ core/messages/en.json | 3 + core/vibes/soul/sections/cart/client.tsx | 157 +++++++++++------- 5 files changed, 174 insertions(+), 56 deletions(-) create mode 100644 .changeset/tender-toys-heal.md diff --git a/.changeset/tender-toys-heal.md b/.changeset/tender-toys-heal.md new file mode 100644 index 000000000..d7296856f --- /dev/null +++ b/.changeset/tender-toys-heal.md @@ -0,0 +1,15 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Add the following messages to each line item on cart page based on store inventory settings: +- Fully/partially out-of-stock message if enabled on the store and the line item is currently out of stock +- Ready-to-ship quantity if enabled on the store +- Backordered quantity if enabled on the store + +## Migration +For existing Catalyst stores, to get the newly added feature, simply rebase the existing code with the new release code. The files to be rebased for this change to be applied are: +- core/app/[locale]/(default)/cart/page-data.ts +- core/app/[locale]/(default)/cart/page.tsx +- core/messages/en.json +- core/vibes/soul/sections/cart/client.tsx diff --git a/core/app/[locale]/(default)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index e1758e1a7..c6e47636d 100644 --- a/core/app/[locale]/(default)/cart/page-data.ts +++ b/core/app/[locale]/(default)/cart/page-data.ts @@ -56,6 +56,12 @@ export const PhysicalItemFragment = graphql(` } } url + stockPosition { + backorderMessage + quantityOnHand + quantityBackordered + quantityOutOfStock + } } `); @@ -206,6 +212,13 @@ const CartPageQuery = graphql( query CartPageQuery($cartId: String, $currencyCode: currencyCode) { site { settings { + inventory { + defaultOutOfStockMessage + showOutOfStockMessage + showBackorderMessage + showQuantityOnBackorder + showQuantityOnHand + } url { checkoutUrl } diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index f78ad9076..bc07f3d47 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -125,6 +125,47 @@ export default async function Cart({ params }: Props) { }; } + let inventoryMessages; + + if (item.__typename === 'CartPhysicalItem') { + if (item.stockPosition?.quantityOutOfStock === item.quantity) { + inventoryMessages = { + outOfStockMessage: data.site.settings?.inventory?.showOutOfStockMessage + ? data.site.settings.inventory.defaultOutOfStockMessage + : undefined, + }; + } else { + inventoryMessages = { + quantityReadyToShipMessage: + data.site.settings?.inventory?.showQuantityOnHand && + !!item.stockPosition?.quantityOnHand + ? t('quantityReadyToShip', { + quantity: Number(item.stockPosition.quantityOnHand), + }) + : undefined, + quantityBackorderedMessage: + data.site.settings?.inventory?.showQuantityOnBackorder && + !!item.stockPosition?.quantityBackordered + ? t('quantityOnBackorder', { + quantity: Number(item.stockPosition.quantityBackordered), + }) + : undefined, + quantityOutOfStockMessage: + data.site.settings?.inventory?.showOutOfStockMessage && + !!item.stockPosition?.quantityOutOfStock + ? t('partiallyAvailable', { + quantity: item.quantity - Number(item.stockPosition.quantityOutOfStock), + }) + : undefined, + backorderMessage: + data.site.settings?.inventory?.showBackorderMessage && + !!item.stockPosition?.quantityBackordered + ? (item.stockPosition.backorderMessage ?? undefined) + : undefined, + }; + } + } + return { typename: item.__typename, id: item.entityId, @@ -165,6 +206,7 @@ export default async function Cart({ params }: Props) { selectedOptions: item.selectedOptions, productEntityId: item.productEntityId, variantEntityId: item.variantEntityId, + inventoryMessages, }; }); diff --git a/core/messages/en.json b/core/messages/en.json index 303e03f42..6c7146472 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -370,6 +370,9 @@ "cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.", "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", + "quantityReadyToShip": "{quantity, number} ready to ship", + "quantityOnBackorder": "{quantity, number} will be backordered", + "partiallyAvailable": "Only {quantity, number} available", "CheckoutSummary": { "title": "Summary", "subTotal": "Subtotal", diff --git a/core/vibes/soul/sections/cart/client.tsx b/core/vibes/soul/sections/cart/client.tsx index cbe49988e..8825d06b3 100644 --- a/core/vibes/soul/sections/cart/client.tsx +++ b/core/vibes/soul/sections/cart/client.tsx @@ -34,6 +34,14 @@ import { CartEmptyState } from '.'; type Action = (state: Awaited, payload: Payload) => State | Promise; +interface CartLineIteminventoryMessages { + outOfStockMessage?: string; + quantityReadyToShipMessage?: string; + quantityBackorderedMessage?: string; + quantityOutOfStockMessage?: string; + backorderMessage?: string; +} + export interface CartLineItem { typename: string; id: string; @@ -44,6 +52,7 @@ export interface CartLineItem { price: string; salePrice?: string; href?: string; + inventoryMessages?: CartLineIteminventoryMessages; } export interface CartGiftCertificateLineItem extends CartLineItem { @@ -563,7 +572,7 @@ function CounterForm({
{lineItem.salePrice && lineItem.salePrice !== lineItem.price ? ( - + {t('originalPrice', { price: lineItem.price })} ) : ( - {lineItem.price} + {lineItem.price} )} - {/* Counter */} -
- - - {lineItem.quantity} - - + > + + + {lineItem.quantity} + + +
+ +
+ {lineItem.inventoryMessages?.outOfStockMessage != null && ( + + {lineItem.inventoryMessages.outOfStockMessage} + + )} + {lineItem.inventoryMessages?.quantityOutOfStockMessage != null && ( + + {lineItem.inventoryMessages.quantityOutOfStockMessage} + + )} + {lineItem.inventoryMessages?.quantityReadyToShipMessage != null && ( + + {lineItem.inventoryMessages.quantityReadyToShipMessage} + + )} + {lineItem.inventoryMessages?.quantityBackorderedMessage != null && ( + + {lineItem.inventoryMessages.quantityBackorderedMessage} + + )} + {lineItem.inventoryMessages?.backorderMessage != null && ( + + {lineItem.inventoryMessages.backorderMessage} + + )} - ); From 6a23c90714b2218db45f17cebe395b21753157e7 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 10 Feb 2026 15:22:27 -0600 Subject: [PATCH 10/17] feat(core): enable product image pagination (#2863) * feat(core): enable product image pagination * feat: infinite gallery, no thumbnail * fix: infinite scroll in thumbnail scroll * fix: retains scroll position * fix: infinite scroll fix * fix: change product image to 12 per page * fix: remove label prop * fix: update loading animation * fix: add accessbility * chore: update changeset * fix: merge steamable functions * fix: update changeset * fix: remove @ts-expect-errors * fix: non-nullable assertion --- .changeset/mighty-zebras-turn.md | 20 ++ .../[slug]/_actions/get-more-images.ts | 54 ++++ .../product/[slug]/_components/reviews.tsx | 5 +- .../(default)/product/[slug]/page-data.ts | 7 +- .../(default)/product/[slug]/page.tsx | 11 +- core/messages/en.json | 2 + core/package.json | 8 +- core/vibes/soul/primitives/carousel/index.tsx | 22 +- .../soul/sections/product-detail/index.tsx | 31 +- .../product-detail/product-gallery.tsx | 267 +++++++++++++++--- core/vibes/soul/sections/reviews/index.tsx | 10 +- .../soul/sections/reviews/review-form.tsx | 9 +- core/vibes/soul/sections/slideshow/index.tsx | 12 +- pnpm-lock.yaml | 111 +++++--- 14 files changed, 456 insertions(+), 113 deletions(-) create mode 100644 .changeset/mighty-zebras-turn.md create mode 100644 core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts diff --git a/.changeset/mighty-zebras-turn.md b/.changeset/mighty-zebras-turn.md new file mode 100644 index 000000000..b7cefb189 --- /dev/null +++ b/.changeset/mighty-zebras-turn.md @@ -0,0 +1,20 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Add pagination support for the product gallery. When a product has more images than the initial page load, new images will load as batches once the user reaches the end of the existing thumbnails. Thumbnail images now will display in horizontal direction in all viewport sizes. + +## Migration + +1. Create the new server action file `core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts` with a GraphQL query to fetch additional product images with pagination. + +2. Update the product page data fetching in `core/app/[locale]/(default)/product/[slug]/page-data.ts` to include `pageInfo` (with `hasNextPage` and `endCursor`) from the images query. + +3. Update `core/app/[locale]/(default)/product/[slug]/page.tsx` to pass the new pagination props (`pageInfo`, `productId`, `loadMoreAction`) to the `ProductDetail` component. + +4. The `ProductGallery` component now accepts optional props for pagination: + - `pageInfo?: { hasNextPage: boolean; endCursor: string | null }` + - `productId?: number` + - `loadMoreAction?: ProductGalleryLoadMoreAction` + +Due to the number of changes, it is recommended to use the PR as a reference for migration. diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts b/core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts new file mode 100644 index 000000000..4d52edc9b --- /dev/null +++ b/core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts @@ -0,0 +1,54 @@ +'use server'; + +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +const MoreProductImagesQuery = graphql(` + query MoreProductImagesQuery($entityId: Int!, $first: Int!, $after: String!) { + site { + product(entityId: $entityId) { + images(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + altText + url: urlTemplate(lossy: true) + } + } + } + } + } + } +`); + +export async function getMoreProductImages( + productId: number, + cursor: string, + limit = 12, +): Promise<{ + images: Array<{ src: string; alt: string }>; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}> { + const customerAccessToken = await getSessionCustomerAccessToken(); + + const { data } = await client.fetch({ + document: MoreProductImagesQuery, + variables: { entityId: productId, first: limit, after: cursor }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + const images = removeEdgesAndNodes(data.site.product?.images ?? { edges: [] }); + + return { + images: images.map((img) => ({ src: img.url, alt: img.altText })), + pageInfo: data.site.product?.images.pageInfo ?? { hasNextPage: false, endCursor: null }, + }; +} diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 4bfc58e35..6429de74b 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -77,7 +77,10 @@ const getReviews = cache(async (productId: number, paginationArgs: object) => { interface Props { productId: number; searchParams: Promise; - streamableImages: Streamable>; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; streamableProduct: Streamable>>; } diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 6a4b6a167..0f7fccc2c 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -274,7 +274,12 @@ const StreamableProductQuery = graphql( optionValueIds: $optionValueIds useDefaultOptionSelections: $useDefaultOptionSelections ) { - images { + entityId + images(first: 12) { + pageInfo { + hasNextPage + endCursor + } edges { node { altText diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index bdde5b123..b43267904 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -14,6 +14,7 @@ import { productOptionsTransformer } from '~/data-transformers/product-options-t import { getPreferredCurrencyCode } from '~/lib/currency'; import { addToCart } from './_actions/add-to-cart'; +import { getMoreProductImages } from './_actions/get-more-images'; import { submitReview } from './_actions/submit-review'; import { ProductAnalyticsProvider } from './_components/product-analytics-provider'; import { ProductSchema } from './_components/product-schema'; @@ -182,9 +183,12 @@ export default async function Product({ params, searchParams }: Props) { alt: image.altText, })); - return product.defaultImage - ? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images] - : images; + return { + images: product.defaultImage + ? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images] + : images, + pageInfo: product.images.pageInfo, + }; }); const streameableCtaLabel = Streamable.from(async () => { @@ -546,6 +550,7 @@ export default async function Product({ params, searchParams }: Props) { emptySelectPlaceholder={t('ProductDetails.emptySelectPlaceholder')} fields={productOptionsTransformer(baseProduct.productOptions)} incrementLabel={t('ProductDetails.increaseQuantity')} + loadMoreImagesAction={getMoreProductImages} prefetch={true} product={{ id: baseProduct.entityId.toString(), diff --git a/core/messages/en.json b/core/messages/en.json index 6c7146472..1f09a5058 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -463,6 +463,8 @@ "additionalInformation": "Additional information", "currentStock": "{quantity, number} in stock", "backorderQuantity": "{quantity, number} will be on backorder", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Add to cart", "outOfStock": "Out of stock", diff --git a/core/package.json b/core/package.json index 3ada39fa5..30f5af80e 100644 --- a/core/package.json +++ b/core/package.json @@ -46,10 +46,10 @@ "clsx": "^2.1.1", "content-security-policy-builder": "^2.3.0", "deepmerge": "^4.3.1", - "embla-carousel": "8.5.2", - "embla-carousel-autoplay": "8.5.2", - "embla-carousel-fade": "8.5.2", - "embla-carousel-react": "8.5.2", + "embla-carousel": "9.0.0-rc01", + "embla-carousel-autoplay": "9.0.0-rc01", + "embla-carousel-fade": "9.0.0-rc01", + "embla-carousel-react": "9.0.0-rc01", "gql.tada": "^1.8.10", "graphql": "^16.11.0", "dompurify": "^3.3.1", diff --git a/core/vibes/soul/primitives/carousel/index.tsx b/core/vibes/soul/primitives/carousel/index.tsx index 701f76635..11e21f804 100644 --- a/core/vibes/soul/primitives/carousel/index.tsx +++ b/core/vibes/soul/primitives/carousel/index.tsx @@ -58,13 +58,13 @@ function Carousel({ const onSelect = React.useCallback((api: CarouselApi) => { if (!api) return; - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); + setCanScrollPrev(api.canGoToPrev()); + setCanScrollNext(api.canGoToNext()); }, []); - const scrollPrev = useCallback(() => api?.scrollPrev(), [api]); + const scrollPrev = useCallback(() => api?.goToPrev(), [api]); - const scrollNext = useCallback(() => api?.scrollNext(), [api]); + const scrollNext = useCallback(() => api?.goToNext(), [api]); const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -89,7 +89,7 @@ function Carousel({ if (!api) return; onSelect(api); - api.on('reInit', onSelect); + api.on('reinit', onSelect); api.on('select', onSelect); return () => { @@ -225,7 +225,7 @@ function CarouselScrollbar({ if (!api) return 0; const point = nextProgress / 100; - const snapList = api.scrollSnapList(); + const snapList = api.snapList(); if (snapList.length === 0) return -1; @@ -241,14 +241,14 @@ function CarouselScrollbar({ useEffect(() => { if (!api) return; - const snapList = api.scrollSnapList(); + const snapList = api.snapList(); const closestSnapIndex = findClosestSnap(progress); const scrollbarWidth = 100 / snapList.length; const scrollbarLeft = (closestSnapIndex / snapList.length) * 100; setScrollbarPosition({ width: scrollbarWidth, left: scrollbarLeft }); - api.scrollTo(closestSnapIndex); + api.goTo(closestSnapIndex); }, [progress, api, findClosestSnap]); useEffect(() => { @@ -258,17 +258,17 @@ function CarouselScrollbar({ if (!api) return; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setProgress(api.scrollSnapList()[api.selectedScrollSnap()]! * 100); + setProgress(api.snapList()[api.selectedSnap()]! * 100); } api.on('select', onScroll); api.on('scroll', onScroll); - api.on('reInit', onScroll); + api.on('reinit', onScroll); return () => { api.off('select', onScroll); api.off('scroll', onScroll); - api.off('reInit', onScroll); + api.off('reinit', onScroll); }; }, [api]); diff --git a/core/vibes/soul/sections/product-detail/index.tsx b/core/vibes/soul/sections/product-detail/index.tsx index ffa219b17..fe08e3e78 100644 --- a/core/vibes/soul/sections/product-detail/index.tsx +++ b/core/vibes/soul/sections/product-detail/index.tsx @@ -6,7 +6,10 @@ import { AnimatedUnderline } from '@/vibes/soul/primitives/animated-underline'; import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label'; import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { type Breadcrumb, Breadcrumbs } from '@/vibes/soul/sections/breadcrumbs'; -import { ProductGallery } from '@/vibes/soul/sections/product-detail/product-gallery'; +import { + ProductGallery, + ProductGalleryLoadMoreAction, +} from '@/vibes/soul/sections/product-detail/product-gallery'; import { ReviewForm, SubmitReviewAction } from '@/vibes/soul/sections/reviews/review-form'; import { @@ -22,7 +25,10 @@ interface ProductDetailProduct { id: string; title: string; href: string; - images: Streamable>; + images: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; price?: Streamable; subtitle?: string; badge?: string; @@ -68,6 +74,7 @@ export interface ProductDetailProps { reviewFormTitleLabel?: string; reviewFormAction: SubmitReviewAction; user: Streamable<{ email: string; name: string }>; + loadMoreImagesAction?: ProductGalleryLoadMoreAction; } // eslint-disable-next-line valid-jsdoc @@ -109,6 +116,7 @@ export function ProductDetail({ reviewFormTitleLabel, reviewFormAction, user, + loadMoreImagesAction, }: ProductDetailProps) { return (
@@ -124,7 +132,14 @@ export function ProductDetail({
} value={product.images}> - {(images) => } + {(imagesData) => ( + + )}
{/* Product Details */} @@ -185,8 +200,14 @@ export function ProductDetail({
} value={product.images}> - {(images) => ( - + {(imagesData) => ( + )}
diff --git a/core/vibes/soul/sections/product-detail/product-gallery.tsx b/core/vibes/soul/sections/product-detail/product-gallery.tsx index 4567bc5b8..be9d0db90 100644 --- a/core/vibes/soul/sections/product-detail/product-gallery.tsx +++ b/core/vibes/soul/sections/product-detail/product-gallery.tsx @@ -1,11 +1,23 @@ 'use client'; import { clsx } from 'clsx'; +import { EmblaCarouselType, EngineType } from 'embla-carousel'; import useEmblaCarousel from 'embla-carousel-react'; -import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { startTransition, useCallback, useEffect, useRef, useState } from 'react'; +import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { Image } from '~/components/image'; +export type ProductGalleryLoadMoreAction = ( + productId: number, + cursor: string, + limit?: number, +) => Promise<{ + images: Array<{ src: string; alt: string }>; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}>; + export interface ProductGalleryProps { images: Array<{ alt: string; src: string }>; className?: string; @@ -23,6 +35,9 @@ export interface ProductGalleryProps { | '5:6' | '6:5'; fit?: 'contain' | 'cover'; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + productId?: number; + loadMoreAction?: ProductGalleryLoadMoreAction; } // eslint-disable-next-line valid-jsdoc @@ -36,42 +51,193 @@ export interface ProductGalleryProps { * --product-gallery-image-background: hsl(var(--contrast-100)); * --product-gallery-image-border: hsl(var(--contrast-100)); * --product-gallery-image-border-active: hsl(var(--foreground)); + * --product-gallery-load-more: hsl(var(--foreground)); * } * ``` */ export function ProductGallery({ - images, + images: initialImages, className, thumbnailLabel = 'View image number', aspectRatio = '4:5', fit = 'contain', + pageInfo: initialPageInfo, + productId, + loadMoreAction, }: ProductGalleryProps) { - const [previewImage, setPreviewImage] = useState(0); + const t = useTranslations('Product.ProductDetails'); + + const [images, setImages] = useState(initialImages); + const [pageInfo, setPageInfo] = useState(initialPageInfo); + const [hasMoreToLoad, setHasMoreToLoad] = useState(initialPageInfo?.hasNextPage ?? false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [loadingStatus, setLoadingStatus] = useState(''); + + const scrollListenerRef = useRef<() => void>(() => undefined); + const listenForScrollRef = useRef(true); + const hasMoreToLoadRef = useRef(hasMoreToLoad); + const [emblaRef, emblaApi] = useEmblaCarousel(); + const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel({ + containScroll: 'keepSnaps', + dragFree: true, + }); + // Keep ref in sync with state useEffect(() => { - if (!emblaApi) return; + hasMoreToLoadRef.current = hasMoreToLoad; + }, [hasMoreToLoad]); + + const onThumbClick = useCallback( + (index: number) => { + if (!emblaApi || !emblaThumbsApi) return; + emblaApi.goTo(index); + }, + [emblaApi, emblaThumbsApi], + ); + + const onSelect = useCallback(() => { + if (!emblaApi || !emblaThumbsApi) return; + setSelectedIndex(emblaApi.selectedSnap()); - const onSelect = () => setPreviewImage(emblaApi.selectedScrollSnap()); + emblaThumbsApi.goTo(emblaApi.selectedSnap()); + }, [emblaApi, emblaThumbsApi]); + useEffect(() => { + if (!emblaApi) return; + onSelect(); emblaApi.on('select', onSelect); return () => { emblaApi.off('select', onSelect); }; - }, [emblaApi]); + }, [emblaApi, onSelect]); + + const onSlideChanges = useCallback((carouselApi: EmblaCarouselType) => { + const reloadEmbla = (): void => { + const oldEngine = carouselApi.internalEngine(); + + carouselApi.reInit(); + + const newEngine = carouselApi.internalEngine(); + const copyEngineModules: Array = [ + 'scrollBody', + 'location', + 'offsetLocation', + 'previousLocation', + 'target', + ]; + + copyEngineModules.forEach((engineModule) => { + Object.assign(newEngine[engineModule], oldEngine[engineModule]); + }); + + newEngine.translate.to(oldEngine.location.get()); + + const { index } = newEngine.scrollTarget.byDistance(0, false); + + newEngine.indexCurrent.set(index); + newEngine.animation.start(); + + listenForScrollRef.current = true; + }; + + const reloadAfterPointerUp = (): void => { + carouselApi.off('pointerup', reloadAfterPointerUp); + reloadEmbla(); + }; + + const engine = carouselApi.internalEngine(); + + if (hasMoreToLoadRef.current && engine.dragHandler.pointerDown()) { + const boundsActive = engine.limit.pastMaxBound(engine.target.get()); + + engine.scrollBounds.toggleActive(boundsActive); + carouselApi.on('pointerup', reloadAfterPointerUp); + } else { + reloadEmbla(); + } + }, []); + + const loadMore = useCallback( + (thumbsApi: EmblaCarouselType) => { + const endCursor = pageInfo?.endCursor; + + if (!loadMoreAction || !productId || !endCursor || isLoading) return; + + listenForScrollRef.current = false; + setIsLoading(true); + setLoadingStatus(t('loadingMoreImages')); + + startTransition(async () => { + const result = await loadMoreAction(productId, endCursor); + + if (!result.pageInfo.hasNextPage) { + setHasMoreToLoad(false); + thumbsApi.off('scroll', scrollListenerRef.current); + } + + setImages((prev) => [...prev, ...result.images]); + setPageInfo(result.pageInfo); + setIsLoading(false); + setLoadingStatus(t('imagesLoaded', { count: result.images.length })); + }); + }, + [loadMoreAction, productId, pageInfo?.endCursor, isLoading, t], + ); + + const onThumbsScroll = useCallback( + (thumbsApi: EmblaCarouselType) => { + if (!listenForScrollRef.current) return; + + const slideCount = thumbsApi.slideNodes().length; + const lastSlideIndex = slideCount - 1; + const secondLastSlideIndex = slideCount - 2; + const slidesInView = thumbsApi.slidesInView(); + + // Trigger when last or second-to-last thumbnail is in view + const shouldLoadMore = + slidesInView.includes(lastSlideIndex) || slidesInView.includes(secondLastSlideIndex); + + if (shouldLoadMore) { + loadMore(thumbsApi); + } + }, + [loadMore], + ); + + const addThumbsScrollListener = useCallback( + (thumbsApi: EmblaCarouselType) => { + scrollListenerRef.current = () => onThumbsScroll(thumbsApi); + thumbsApi.on('scroll', scrollListenerRef.current); + }, + [onThumbsScroll], + ); - const selectImage = (index: number) => { - setPreviewImage(index); - if (emblaApi) emblaApi.scrollTo(index); - }; + useEffect(() => { + if (!emblaThumbsApi) return; + + addThumbsScrollListener(emblaThumbsApi); + + const onResize = () => emblaThumbsApi.reInit(); + + window.addEventListener('resize', onResize); + emblaThumbsApi.on('destroy', () => window.removeEventListener('resize', onResize)); + emblaThumbsApi.on('slideschanged', onSlideChanges); + + return () => { + emblaThumbsApi.off('scroll', scrollListenerRef.current); + emblaThumbsApi.off('slideschanged', onSlideChanges); + }; + }, [emblaThumbsApi, addThumbsScrollListener, onSlideChanges]); return ( -
-
+
+
+ {loadingStatus} +
+
{images.map((image, idx) => (
-
- {images.map((image, index) => ( - + ))} + {hasMoreToLoad && ( +
+ + + + + +
)} - key={index} - onClick={() => selectImage(index)} - > -
- {image.alt} -
- - ))} +
+
); diff --git a/core/vibes/soul/sections/reviews/index.tsx b/core/vibes/soul/sections/reviews/index.tsx index d248f66f2..b27c75f97 100644 --- a/core/vibes/soul/sections/reviews/index.tsx +++ b/core/vibes/soul/sections/reviews/index.tsx @@ -34,7 +34,10 @@ interface Props { formReviewLabel?: string; formNameLabel?: string; formEmailLabel?: string; - streamableImages: Streamable>; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; } @@ -212,7 +215,10 @@ export function ReviewsEmptyState({ formReviewLabel?: string; formNameLabel?: string; formEmailLabel?: string; - streamableImages: Streamable>; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; }) { diff --git a/core/vibes/soul/sections/reviews/review-form.tsx b/core/vibes/soul/sections/reviews/review-form.tsx index 1f72c818b..49e263c84 100644 --- a/core/vibes/soul/sections/reviews/review-form.tsx +++ b/core/vibes/soul/sections/reviews/review-form.tsx @@ -38,7 +38,10 @@ interface Props { formReviewLabel?: string; formNameLabel?: string; formEmailLabel?: string; - streamableImages: Streamable>; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; } @@ -126,8 +129,8 @@ export const ReviewForm = ({ } value={Streamable.all([streamableProduct, streamableImages])} > - {([product, images]) => { - const firstImage = images[0]; + {([product, imagesData]) => { + const firstImage = imagesData.images[0]; return ( <> diff --git a/core/vibes/soul/sections/slideshow/index.tsx b/core/vibes/soul/sections/slideshow/index.tsx index 80594992d..f98a1c5fa 100644 --- a/core/vibes/soul/sections/slideshow/index.tsx +++ b/core/vibes/soul/sections/slideshow/index.tsx @@ -51,18 +51,18 @@ const useProgressButton = ( const onProgressButtonClick = useCallback( (index: number) => { if (!emblaApi) return; - emblaApi.scrollTo(index); + emblaApi.goTo(index); if (onButtonClick) onButtonClick(emblaApi); }, [emblaApi, onButtonClick], ); const onInit = useCallback((emblaAPI: EmblaCarouselType) => { - setScrollSnaps(emblaAPI.scrollSnapList()); + setScrollSnaps(emblaAPI.snapList()); }, []); const onSelect = useCallback((emblaAPI: EmblaCarouselType) => { - setSelectedIndex(emblaAPI.selectedScrollSnap()); + setSelectedIndex(emblaAPI.selectedSnap()); }, []); useEffect(() => { @@ -71,7 +71,7 @@ const useProgressButton = ( onInit(emblaApi); onSelect(emblaApi); - emblaApi.on('reInit', onInit).on('reInit', onSelect).on('select', onSelect); + emblaApi.on('reinit', onInit).on('reinit', onSelect).on('select', onSelect); }, [emblaApi, onInit, onSelect]); return { @@ -106,7 +106,7 @@ const useProgressButton = ( */ export function Slideshow({ slides, playOnInit = true, interval = 5000, className }: Props) { const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 20 }, [ - Autoplay({ delay: interval, playOnInit }), + Autoplay({ delay: interval, active: playOnInit }), Fade(), ]); const { selectedIndex, scrollSnaps, onProgressButtonClick } = useProgressButton(emblaApi); @@ -145,7 +145,7 @@ export function Slideshow({ slides, playOnInit = true, interval = 5000, classNam .on('autoplay:stop', () => { setIsPlaying(false); }) - .on('reInit', () => { + .on('reinit', () => { setIsPlaying(autoplay.isPlaying()); }); }, [emblaApi, playCount]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbc27dcfc..9c6946782 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,17 +135,17 @@ importers: specifier: ^3.3.1 version: 3.3.1 embla-carousel: - specifier: 8.5.2 - version: 8.5.2 + specifier: 9.0.0-rc01 + version: 9.0.0-rc01 embla-carousel-autoplay: - specifier: 8.5.2 - version: 8.5.2(embla-carousel@8.5.2) + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) embla-carousel-fade: - specifier: 8.5.2 - version: 8.5.2(embla-carousel@8.5.2) + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) embla-carousel-react: - specifier: 8.5.2 - version: 8.5.2(react@19.1.5) + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(react@19.1.5) gql.tada: specifier: ^1.8.10 version: 1.8.10(graphql@16.11.0)(typescript@5.8.3) @@ -5997,28 +5997,28 @@ packages: electron-to-chromium@1.5.165: resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==} - embla-carousel-autoplay@8.5.2: - resolution: {integrity: sha512-27emJ0px3q/c0kCHCjwRrEbYcyYUPfGO3g5IBWF1i7714TTzE6L9P81V6PHLoSMAKJ1aHoT2e7YFOsuFKCbyag==} + embla-carousel-autoplay@9.0.0-rc01: + resolution: {integrity: sha512-gl7jUe0X9xd5v7IiyFF2FCR+KTBesnutM0gQ3S75oo9EizZi5tJMNdVaFwvzepz6kGzDkkSbKMWitQvAbFVfdQ==} peerDependencies: - embla-carousel: 8.5.2 + embla-carousel: 9.0.0-rc01 - embla-carousel-fade@8.5.2: - resolution: {integrity: sha512-QJ46Xy+mpijjquQeIY0d0sPSy34XduREUnz7tn1K20hcKyZYTONNIXQZu3GGNwG59cvhMqYJMw9ki92Rjd14YA==} + embla-carousel-fade@9.0.0-rc01: + resolution: {integrity: sha512-sIpJaJmcrp7+vm5r/XHEqDcCo5Fg29DdL/2Za5FQ2k//ePqapnPPzfUeG018uPT/6ylBjhn007s4sRkilbu2GA==} peerDependencies: - embla-carousel: 8.5.2 + embla-carousel: 9.0.0-rc01 - embla-carousel-react@8.5.2: - resolution: {integrity: sha512-Tmx+uY3MqseIGdwp0ScyUuxpBgx5jX1f7od4Cm5mDwg/dptEiTKf9xp6tw0lZN2VA9JbnVMl/aikmbc53c6QFA==} + embla-carousel-react@9.0.0-rc01: + resolution: {integrity: sha512-2ik9QtVm3UXJWkVdEEm6bInmxNSmxq9Z2q5GWuJx3v2vZvujmlDzcrIE6bvh+wWgPmDn6jekJCRHm1eEl/N0SA==} peerDependencies: react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - embla-carousel-reactive-utils@8.5.2: - resolution: {integrity: sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==} + embla-carousel-reactive-utils@9.0.0-rc01: + resolution: {integrity: sha512-RnW0NMrL7wVAQb9jro+l96hLI2JairyFHS2Jv+fvXakveD/c5aD9aoNH94YRbTmi0G7PxrKSxydmCpTy5eFmrA==} peerDependencies: - embla-carousel: 8.5.2 + embla-carousel: 9.0.0-rc01 - embla-carousel@8.5.2: - resolution: {integrity: sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==} + embla-carousel@9.0.0-rc01: + resolution: {integrity: sha512-4BTERU1gAXgg4Vl0m7hQ1GzePGLNNfM2j030ww8i9idiPXumyRUpaNUDfT2zx1Hv8um1Ew7QKBy/HdNPz8L30g==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -8701,7 +8701,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: @@ -17122,25 +17121,25 @@ snapshots: electron-to-chromium@1.5.165: {} - embla-carousel-autoplay@8.5.2(embla-carousel@8.5.2): + embla-carousel-autoplay@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: - embla-carousel: 8.5.2 + embla-carousel: 9.0.0-rc01 - embla-carousel-fade@8.5.2(embla-carousel@8.5.2): + embla-carousel-fade@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: - embla-carousel: 8.5.2 + embla-carousel: 9.0.0-rc01 - embla-carousel-react@8.5.2(react@19.1.5): + embla-carousel-react@9.0.0-rc01(react@19.1.5): dependencies: - embla-carousel: 8.5.2 - embla-carousel-reactive-utils: 8.5.2(embla-carousel@8.5.2) + embla-carousel: 9.0.0-rc01 + embla-carousel-reactive-utils: 9.0.0-rc01(embla-carousel@9.0.0-rc01) react: 19.1.5 - embla-carousel-reactive-utils@8.5.2(embla-carousel@8.5.2): + embla-carousel-reactive-utils@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: - embla-carousel: 8.5.2 + embla-carousel: 9.0.0-rc01 - embla-carousel@8.5.2: {} + embla-carousel@9.0.0-rc01: {} emittery@0.13.1: {} @@ -17369,8 +17368,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -17412,6 +17411,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.1 + eslint: 8.57.1 + get-tsconfig: 4.10.0 + is-bun-module: 1.3.0 + rspack-resolver: 1.2.2 + stable-hash: 0.0.5 + tinyglobby: 0.2.14 + optionalDependencies: + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 @@ -17462,6 +17476,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jest-dom@5.5.0(eslint@8.57.1): dependencies: '@babel/runtime': 7.26.7 From f5330c7248b2e3a32b2bfbb8e3bc6c11742a5d27 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 11 Feb 2026 13:34:33 -0600 Subject: [PATCH 11/17] feat(core): add canonical URLs and hreflang alternates for SEO (#2856) * feat(core): add canonical URLs and hreflang alternates for SEO * feat(core): add canonical URLs to additional pages Add canonical meta tags to gift certificates, contact, public wishlist, and compare pages. Update changeset with migration steps for all pages. Co-Authored-By: Claude Opus 4.5 * fix(core): resolve eslint errors in canonical utility - Add JSDoc param and returns type annotations - Replace for...of loop with reduce for array iteration Co-Authored-By: Claude Opus 4.5 * fix: fetch vanity url * chore: update changeset * fix: use URL --------- Co-authored-by: Claude Opus 4.5 --- .changeset/new-onions-flash.md | 200 ++++++++++++++++++ .../(faceted)/brand/[slug]/page-data.ts | 1 + .../(default)/(faceted)/brand/[slug]/page.tsx | 4 +- .../(faceted)/category/[slug]/page.tsx | 9 +- .../(default)/blog/[blogId]/page-data.ts | 1 + .../[locale]/(default)/blog/[blogId]/page.tsx | 6 +- core/app/[locale]/(default)/blog/page.tsx | 2 + core/app/[locale]/(default)/compare/page.tsx | 2 + .../gift-certificates/balance/page.tsx | 2 + .../(default)/gift-certificates/page.tsx | 2 + .../gift-certificates/purchase/page.tsx | 13 ++ core/app/[locale]/(default)/page.tsx | 10 + .../(default)/product/[slug]/page-data.ts | 1 + .../(default)/product/[slug]/page.tsx | 4 +- .../(default)/webpages/[id]/contact/page.tsx | 4 +- .../(default)/webpages/[id]/normal/page.tsx | 7 +- .../(default)/wishlist/[token]/page.tsx | 2 + core/app/[locale]/layout.tsx | 6 + core/lib/seo/canonical.ts | 99 +++++++++ 19 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 .changeset/new-onions-flash.md create mode 100644 core/lib/seo/canonical.ts diff --git a/.changeset/new-onions-flash.md b/.changeset/new-onions-flash.md new file mode 100644 index 000000000..7bc9afcce --- /dev/null +++ b/.changeset/new-onions-flash.md @@ -0,0 +1,200 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Add canonical URLs and hreflang alternates for SEO. Pages now set `alternates.canonical` and `alternates.languages` in `generateMetadata` via the new `getMetadataAlternates` helper in `core/lib/seo/canonical.ts`. The helper fetches the vanity URL via GraphQL (`site.settings.url.vanityUrl`) and is cached per request. The default locale uses no path prefix; other locales use `/{locale}/path`. The root locale layout sets `metadataBase` to the configured vanity URL so canonical URLs resolve correctly. + +## Migration steps + +### Step 1: Root layout metadata base + +The root locale layout now sets `metadataBase` from the vanity URL fetched via GraphQL. This is already included in the `RootLayoutMetadataQuery`. + +Update `core/app/[locale]/layout.tsx`: + +```diff ++ const vanityUrl = data.site.settings?.url.vanityUrl; ++ + return { ++ metadataBase: vanityUrl ? new URL(vanityUrl) : undefined, + title: { +``` + +### Step 2: GraphQL fragment updates + +Add the `path` field to brand, blog post, and product queries so metadata can build canonical URLs. + +Update `core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts`: + +```diff + site { + brand(entityId: $entityId) { + name ++ path + seo { +``` + +Update `core/app/[locale]/(default)/blog/[blogId]/page-data.ts`: + +```diff + author + htmlBody + name ++ path + publishedDate { +``` + +Update `core/app/[locale]/(default)/product/[slug]/page-data.ts` (in the metadata query): + +```diff + site { + product(entityId: $entityId) { + name ++ path + defaultImage { +``` + +### Step 3: Page metadata alternates + +Add the `getMetadataAlternates` import and set `alternates` in `generateMetadata` for each page. The function is async and must be awaited. Ensure `core/lib/seo/canonical.ts` exists (it is included in this release). + +Update `core/app/[locale]/(default)/page.tsx` (home): + +```diff ++ import { Metadata } from 'next'; + import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; + ... ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... ++ export async function generateMetadata({ params }: Props): Promise { ++ const { locale } = await params; ++ return { ++ alternates: await getMetadataAlternates({ path: '/', locale }), ++ }; ++ } ++ + export default async function Home({ params }: Props) { +``` + +For entity pages (product, category, brand, blog, blog post, webpage), add the import and include `alternates` in the existing `generateMetadata` return value using the entity `path` (or breadcrumb-derived path for category and webpage). Example for a brand page: + +```diff ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... + export async function generateMetadata(props: Props): Promise { +- const { slug } = await props.params; ++ const { slug, locale } = await props.params; + ... + return { + title: pageTitle || brand.name, + description: metaDescription, + keywords: metaKeywords ? metaKeywords.split(',') : null, ++ alternates: await getMetadataAlternates({ path: brand.path, locale }), + }; + } +``` + +### Step 4: Gift certificates pages + +Update `core/app/[locale]/(default)/gift-certificates/page.tsx`: + +```diff ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... + export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + + return { + title: t('title') || 'Gift certificates', ++ alternates: await getMetadataAlternates({ path: '/gift-certificates', locale }), + }; + } +``` + +Update `core/app/[locale]/(default)/gift-certificates/balance/page.tsx`: + +```diff ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... + return { + title: t('title') || 'Gift certificates - Check balance', ++ alternates: await getMetadataAlternates({ path: '/gift-certificates/balance', locale }), + }; +``` + +Add `generateMetadata` to `core/app/[locale]/(default)/gift-certificates/purchase/page.tsx`: + +```diff ++ import { Metadata } from 'next'; + import { getFormatter, getTranslations } from 'next-intl/server'; + ... ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... ++ export async function generateMetadata({ params }: Props): Promise { ++ const { locale } = await params; ++ const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); ++ ++ return { ++ title: t('Purchase.title'), ++ alternates: await getMetadataAlternates({ path: '/gift-certificates/purchase', locale }), ++ }; ++ } +``` + +### Step 5: Contact page + +Update `core/app/[locale]/(default)/webpages/[id]/contact/page.tsx`: + +```diff ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... + export async function generateMetadata({ params }: Props): Promise { +- const { id } = await params; ++ const { id, locale } = await params; + const webpage = await getWebPage(id); + const { pageTitle, metaDescription, metaKeywords } = webpage.seo; + + return { + title: pageTitle || webpage.title, + description: metaDescription, + keywords: metaKeywords ? metaKeywords.split(',') : null, ++ alternates: await getMetadataAlternates({ path: webpage.path, locale }), + }; + } +``` + +### Step 6: Public wishlist page + +Update `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: + +```diff ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... + export async function generateMetadata({ params, searchParams }: Props): Promise { + const { locale, token } = await params; + ... + return { + title: wishlist?.name ?? t('title'), ++ alternates: await getMetadataAlternates({ path: `/wishlist/${token}`, locale }), + }; + } +``` + +### Step 7: Compare page + +Update `core/app/[locale]/(default)/compare/page.tsx`: + +```diff ++ import { getMetadataAlternates } from '~/lib/seo/canonical'; + ... + export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'Compare' }); + + return { + title: t('title'), ++ alternates: await getMetadataAlternates({ path: '/compare', locale }), + }; + } +``` diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts index 918affded..9bb605d21 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page-data.ts @@ -9,6 +9,7 @@ const BrandPageQuery = graphql(` site { brand(entityId: $entityId) { name + path seo { pageTitle metaDescription diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 407892f55..163419493 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -13,6 +13,7 @@ import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { MAX_COMPARE_LIMIT } from '../../../compare/page-data'; import { getCompareProducts as getCompareProductsData } from '../../fetch-compare-products'; @@ -67,7 +68,7 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug } = await props.params; + const { slug, locale } = await props.params; const customerAccessToken = await getSessionCustomerAccessToken(); const brandId = Number(slug); @@ -84,6 +85,7 @@ export async function generateMetadata(props: Props): Promise { title: pageTitle || brand.name, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + alternates: await getMetadataAlternates({ path: brand.path, locale }), }; } diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index e09abcf7b..3471282dd 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -14,6 +14,7 @@ import { facetsTransformer } from '~/data-transformers/facets-transformer'; import { pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { MAX_COMPARE_LIMIT } from '../../../compare/page-data'; import { getCompareProducts } from '../../fetch-compare-products'; @@ -69,7 +70,7 @@ interface Props { } export async function generateMetadata(props: Props): Promise { - const { slug } = await props.params; + const { slug, locale } = await props.params; const customerAccessToken = await getSessionCustomerAccessToken(); const categoryId = Number(slug); @@ -82,10 +83,16 @@ export async function generateMetadata(props: Props): Promise { const { pageTitle, metaDescription, metaKeywords } = category.seo; + const breadcrumbs = removeEdgesAndNodes(category.breadcrumbs); + const categoryPath = breadcrumbs[breadcrumbs.length - 1]?.path; + return { title: pageTitle || category.name, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(categoryPath && { + alternates: await getMetadataAlternates({ path: categoryPath, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts index ec480d77b..472c44059 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page-data.ts +++ b/core/app/[locale]/(default)/blog/[blogId]/page-data.ts @@ -15,6 +15,7 @@ const BlogPageQuery = graphql(` author htmlBody name + path publishedDate { utc } diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index e6bda68e8..35ba4ffa9 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -5,6 +5,7 @@ import { cache } from 'react'; import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; import { Breadcrumb } from '@/vibes/soul/sections/breadcrumbs'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlogPageData } from './page-data'; @@ -18,7 +19,7 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { blogId } = await params; + const { blogId, locale } = await params; const variables = cachedBlogPageDataVariables(blogId); @@ -35,6 +36,9 @@ export async function generateMetadata({ params }: Props): Promise { title: pageTitle || blogPost.name, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(blogPost.path && { + alternates: await getMetadataAlternates({ path: blogPost.path, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index b0756183f..e960967c5 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -7,6 +7,7 @@ import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/ser import { Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedBlogPostList } from '@/vibes/soul/sections/featured-blog-post-list'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlog, getBlogPosts } from './page-data'; @@ -36,6 +37,7 @@ export async function generateMetadata({ params }: Props): Promise { blog?.description && blog.description.length > 150 ? `${blog.description.substring(0, 150)}...` : blog?.description, + ...(blog?.path && { alternates: await getMetadataAlternates({ path: blog.path, locale }) }), }; } diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 79ed057c0..ac851ddb1 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -8,6 +8,7 @@ import { CompareSection } from '@/vibes/soul/sections/compare-section'; import { getSessionCustomerAccessToken } from '~/auth'; import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; import { CompareAnalyticsProvider } from './_components/compare-analytics-provider'; @@ -44,6 +45,7 @@ export async function generateMetadata({ params }: Props): Promise { return { title: t('title'), + alternates: await getMetadataAlternates({ path: '/compare', locale }), }; } diff --git a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx index d56106a6e..0320e48ac 100644 --- a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -4,6 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { GiftCertificateCheckBalanceSection } from '@/vibes/soul/sections/gift-certificate-balance-section'; import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getGiftCertificatesData } from '../page-data'; @@ -20,6 +21,7 @@ export async function generateMetadata({ params }: Props): Promise { return { title: t('title') || 'Gift certificates - Check balance', + alternates: await getMetadataAlternates({ path: '/gift-certificates/balance', locale }), }; } diff --git a/core/app/[locale]/(default)/gift-certificates/page.tsx b/core/app/[locale]/(default)/gift-certificates/page.tsx index e0117c53f..5c50984fb 100644 --- a/core/app/[locale]/(default)/gift-certificates/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -4,6 +4,7 @@ import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/serve import { GiftCertificatesSection } from '@/vibes/soul/sections/gift-certificates-section'; import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getGiftCertificatesData } from './page-data'; @@ -18,6 +19,7 @@ export async function generateMetadata({ params }: Props): Promise { return { title: t('title') || 'Gift certificates', + alternates: await getMetadataAlternates({ path: '/gift-certificates', locale }), }; } diff --git a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx index 74f60684f..29e4158c7 100644 --- a/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/purchase/page.tsx @@ -1,4 +1,5 @@ import { ResultOf } from 'gql.tada'; +import { Metadata } from 'next'; import { getFormatter, getTranslations } from 'next-intl/server'; import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; @@ -7,6 +8,7 @@ import { GiftCertificateSettingsFragment } from '~/app/[locale]/(default)/gift-c import { ExistingResultType } from '~/client/util'; import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addGiftCertificateToCart } from './_actions/add-to-cart'; import { getGiftCertificatePurchaseData } from './page-data'; @@ -15,6 +17,17 @@ interface Props { params: Promise<{ locale: string }>; } +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + const t = await getTranslations({ locale, namespace: 'GiftCertificates' }); + + return { + title: t('Purchase.title'), + alternates: await getMetadataAlternates({ path: '/gift-certificates/purchase', locale }), + }; +} + function getFields( giftCertificateSettings: ResultOf, expiresAt: string | undefined, diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 0e34bc75a..77cf4db61 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -1,4 +1,5 @@ import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { Metadata } from 'next'; import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; @@ -8,6 +9,7 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { Subscribe } from '~/components/subscribe'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { Slideshow } from './_components/slideshow'; import { getPageData } from './page-data'; @@ -16,6 +18,14 @@ interface Props { params: Promise<{ locale: string }>; } +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + + return { + alternates: await getMetadataAlternates({ path: '/', locale }), + }; +} + export default async function Home({ params }: Props) { const { locale } = await params; diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 0f7fccc2c..0e1931e18 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -138,6 +138,7 @@ const ProductPageMetadataQuery = graphql(` site { product(entityId: $entityId) { name + path defaultImage { altText url: urlTemplate(lossy: true) diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index b43267904..e29496cd4 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 { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; import { getMoreProductImages } from './_actions/get-more-images'; @@ -37,7 +38,7 @@ interface Props { } export async function generateMetadata({ params }: Props): Promise { - const { slug } = await params; + const { slug, locale } = await params; const customerAccessToken = await getSessionCustomerAccessToken(); const productId = Number(slug); @@ -55,6 +56,7 @@ export async function generateMetadata({ params }: Props): Promise { title: pageTitle || product.name, description: metaDescription || `${product.plainTextDescription.slice(0, 150)}...`, keywords: metaKeywords ? metaKeywords.split(',') : null, + alternates: await getMetadataAlternates({ path: product.path, locale }), openGraph: url ? { images: [ diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index 144381f12..e053978db 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 { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPage, WebPageContent } from '../_components/web-page'; @@ -153,7 +154,7 @@ async function getContactFields(id: string) { } export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; + const { id, locale } = await params; const webpage = await getWebPage(id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; @@ -161,6 +162,7 @@ export async function generateMetadata({ params }: Props): Promise { title: pageTitle || webpage.title, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + alternates: await getMetadataAlternates({ path: webpage.path, locale }), }; } diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index a92303044..6406c381d 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -9,6 +9,7 @@ import { breadcrumbsTransformer, truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPageContent, WebPage as WebPageData } from '../_components/web-page'; @@ -57,14 +58,18 @@ async function getWebPageBreadcrumbs(id: string): Promise { } export async function generateMetadata({ params }: Props): Promise { - const { id } = await params; + const { id, locale } = await params; const webpage = await getWebPage(id); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; + // Get the path from the last breadcrumb + const pagePath = webpage.breadcrumbs[webpage.breadcrumbs.length - 1]?.href; + return { title: pageTitle || webpage.title, description: metaDescription, keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(pagePath && { alternates: await getMetadataAlternates({ path: pagePath, locale }) }), }; } diff --git a/core/app/[locale]/(default)/wishlist/[token]/page.tsx b/core/app/[locale]/(default)/wishlist/[token]/page.tsx index 0ee9fc40b..e6fe52378 100644 --- a/core/app/[locale]/(default)/wishlist/[token]/page.tsx +++ b/core/app/[locale]/(default)/wishlist/[token]/page.tsx @@ -19,6 +19,7 @@ import { } from '~/components/wishlist/share-button'; import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer'; import { publicWishlistDetailsTransformer } from '~/data-transformers/wishlists-transformer'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { isMobileUser } from '~/lib/user-agent'; import { getPublicWishlist } from './page-data'; @@ -73,6 +74,7 @@ export async function generateMetadata({ params, searchParams }: Props): Promise return { title: wishlist?.name ?? t('title'), + alternates: await getMetadataAlternates({ path: `/wishlist/${token}`, locale }), }; } diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index 14fc07ce5..ae7642bd3 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -30,6 +30,9 @@ const RootLayoutMetadataQuery = graphql( query RootLayoutMetadataQuery { site { settings { + url { + vanityUrl + } privacy { cookieConsentEnabled privacyPolicyUrl @@ -68,7 +71,10 @@ export async function generateMetadata(): Promise { const { pageTitle, metaDescription, metaKeywords } = data.site.settings?.seo || {}; + const vanityUrl = data.site.settings?.url.vanityUrl; + return { + metadataBase: vanityUrl ? new URL(vanityUrl) : undefined, title: { template: `%s - ${storeName}`, default: pageTitle || storeName, diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts new file mode 100644 index 000000000..03f9cac3f --- /dev/null +++ b/core/lib/seo/canonical.ts @@ -0,0 +1,99 @@ +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { defaultLocale, locales } from '~/i18n/locales'; + +interface CanonicalUrlOptions { + /** + * The path from BigCommerce (e.g., product.path, category.path) + * or a manually constructed path for static pages (e.g., '/') + */ + path: string; + /** + * Current locale from params + */ + locale: string; + /** + * Whether to include hreflang alternates for all locales + * @default true + */ + includeAlternates?: boolean; +} + +/** + * Generates metadata alternates object for Next.js Metadata API + * + * Rules: + * - Default locale: no prefix (e.g., https://example.com/product/) + * - Other locales: with prefix (e.g., https://example.com/fr/product/) + * - Respects TRAILING_SLASH environment variable + * + * @param {CanonicalUrlOptions} options - The options for generating canonical URLs + * @returns {object} The metadata alternates object with canonical URL and optional language alternates + */ +const VanityUrlQuery = graphql(` + query VanityUrlQuery { + site { + settings { + url { + vanityUrl + } + } + } + } +`); + +const getVanityUrl = cache(async () => { + const { data } = await client.fetch({ + document: VanityUrlQuery, + fetchOptions: { next: { revalidate } }, + }); + + const vanityUrl = data.site.settings?.url.vanityUrl; + + if (!vanityUrl) { + throw new Error('Vanity URL not found in site settings'); + } + + return vanityUrl; +}); + +export async function getMetadataAlternates(options: CanonicalUrlOptions) { + const { path, locale, includeAlternates = true } = options; + + const baseUrl = await getVanityUrl(); + + const canonical = buildLocalizedUrl(baseUrl, path, locale); + + if (!includeAlternates) { + return { canonical }; + } + + const languages = locales.reduce>((acc, loc) => { + acc[loc] = buildLocalizedUrl(baseUrl, path, loc); + + return acc; + }, {}); + + languages['x-default'] = buildLocalizedUrl(baseUrl, path, defaultLocale); + + return { canonical, languages }; +} + +function buildLocalizedUrl(baseUrl: string, pathname: string, locale: string): string { + const trailingSlash = process.env.TRAILING_SLASH !== 'false'; + + const url = new URL(pathname, baseUrl); + + url.pathname = locale === defaultLocale ? url.pathname : `/${locale}${url.pathname}`; + + if (trailingSlash && !url.pathname.endsWith('/')) { + url.pathname += '/'; + } else if (!trailingSlash && url.pathname.endsWith('/') && url.pathname !== '/') { + url.pathname = url.pathname.slice(0, -1); + } + + return url.href; +} From 18cfdc8ca018c33ea49b462ecb6f055a153cd4ab Mon Sep 17 00:00:00 2001 From: Tharaa <36555311+Tharaae@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:06:33 +1100 Subject: [PATCH 12/17] feat: Remove caching on fetching product inventory data (#2801) * feat: Remove caching on fetching product inventory data * feat: Remove caching on fetching product inventory data - Apply comments --- .changeset/eager-nails-bake.md | 10 ++++ .../(default)/product/[slug]/page-data.ts | 56 ++++++++++++++----- .../(default)/product/[slug]/page.tsx | 35 ++++++++---- 3 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 .changeset/eager-nails-bake.md diff --git a/.changeset/eager-nails-bake.md b/.changeset/eager-nails-bake.md new file mode 100644 index 000000000..cce2f5369 --- /dev/null +++ b/.changeset/eager-nails-bake.md @@ -0,0 +1,10 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Fetch product inventory data with a separate GQL query with no caching + +## Migration +The files to be rebased for this change to be applied are: +- core/app/[locale]/(default)/product/[slug]/page-data.ts +- core/app/[locale]/(default)/product/[slug]/page.tsx \ No newline at end of file diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 0e1931e18..02a829373 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -211,7 +211,7 @@ export const getProduct = cache(async (entityId: number, customerAccessToken?: s return data.site; }); -const StreamableProductVariantBySkuQuery = graphql(` +const StreamableProductVariantInventoryBySkuQuery = graphql(` query ProductVariantBySkuQuery($productId: Int!, $sku: String!) { site { product(entityId: $productId) { @@ -247,15 +247,15 @@ const StreamableProductVariantBySkuQuery = graphql(` } `); -type VariantVariables = VariablesOf; +type VariantInventoryVariables = VariablesOf; -export const getStreamableProductVariant = cache( - async (variables: VariantVariables, customerAccessToken?: string) => { +export const getStreamableProductVariantInventory = cache( + async (variables: VariantInventoryVariables, customerAccessToken?: string) => { const { data } = await client.fetch({ - document: StreamableProductVariantBySkuQuery, + document: StreamableProductVariantInventoryBySkuQuery, variables, customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, }); return data.site.product?.variants; @@ -311,6 +311,36 @@ const StreamableProductQuery = graphql( minPurchaseQuantity maxPurchaseQuantity warranty + ...ProductViewedFragment + ...ProductSchemaFragment + } + } + } + `, + [ProductViewedFragment, ProductSchemaFragment], +); + +type Variables = VariablesOf; + +export const getStreamableProduct = cache( + async (variables: Variables, customerAccessToken?: string) => { + const { data } = await client.fetch({ + document: StreamableProductQuery, + variables, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); + + return data.site.product; + }, +); + +const StreamableProductInventoryQuery = graphql( + ` + query StreamableProductInventoryQuery($entityId: Int!) { + site { + product(entityId: $entityId) { + sku inventory { hasVariantInventory isInStock @@ -325,25 +355,23 @@ const StreamableProductQuery = graphql( availabilityV2 { status } - ...ProductViewedFragment ...ProductVariantsInventoryFragment - ...ProductSchemaFragment } } } `, - [ProductViewedFragment, ProductSchemaFragment, ProductVariantsInventoryFragment], + [ProductVariantsInventoryFragment], ); -type Variables = VariablesOf; +type ProductInventoryVariables = VariablesOf; -export const getStreamableProduct = cache( - async (variables: Variables, customerAccessToken?: string) => { +export const getStreamableProductInventory = cache( + async (variables: ProductInventoryVariables, customerAccessToken?: string) => { const { data } = await client.fetch({ - document: StreamableProductQuery, + document: StreamableProductInventoryQuery, variables, customerAccessToken, - fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 60 } }, }); return data.site.product; diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index e29496cd4..5f31e4dad 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -29,7 +29,8 @@ import { getProductPricingAndRelatedProducts, getStreamableInventorySettingsQuery, getStreamableProduct, - getStreamableProductVariant, + getStreamableProductInventory, + getStreamableProductVariantInventory, } from './page-data'; interface Props { @@ -120,8 +121,22 @@ export default async function Product({ params, searchParams }: Props) { const streamableProductSku = Streamable.from(async () => (await streamableProduct).sku); - const streamableProductVariant = Streamable.from(async () => { - const product = await streamableProduct; + const streamableProductInventory = Streamable.from(async () => { + const variables = { + entityId: Number(productId), + }; + + const product = await getStreamableProductInventory(variables, customerAccessToken); + + if (!product) { + return notFound(); + } + + return product; + }); + + const streamableProductVariantInventory = Streamable.from(async () => { + const product = await streamableProductInventory; if (!product.inventory.hasVariantInventory) { return undefined; @@ -132,7 +147,7 @@ export default async function Product({ params, searchParams }: Props) { sku: product.sku, }; - const variants = await getStreamableProductVariant(variables, customerAccessToken); + const variants = await getStreamableProductVariantInventory(variables, customerAccessToken); if (!variants) { return undefined; @@ -194,7 +209,7 @@ export default async function Product({ params, searchParams }: Props) { }); const streameableCtaLabel = Streamable.from(async () => { - const product = await streamableProduct; + const product = await streamableProductInventory; if (product.availabilityV2.status === 'Unavailable') { return t('ProductDetails.Submit.unavailable'); @@ -212,7 +227,7 @@ export default async function Product({ params, searchParams }: Props) { }); const streameableCtaDisabled = Streamable.from(async () => { - const product = await streamableProduct; + const product = await streamableProductInventory; if (product.availabilityV2.status === 'Unavailable') { return true; @@ -259,8 +274,8 @@ export default async function Product({ params, searchParams }: Props) { const streamableStockDisplayData = Streamable.from(async () => { const [product, variant, inventorySetting] = await Streamable.all([ - streamableProduct, - streamableProductVariant, + streamableProductInventory, + streamableProductVariantInventory, streamableInventorySettings, ]); @@ -349,8 +364,8 @@ export default async function Product({ params, searchParams }: Props) { const streamableBackorderDisplayData = Streamable.from(async () => { const [product, variant, inventorySetting] = await Streamable.all([ - streamableProduct, - streamableProductVariant, + streamableProductInventory, + streamableProductVariantInventory, streamableInventorySettings, ]); From 169cbb5719ba49cbe11bcd9fa3de30a01c8edfb9 Mon Sep 17 00:00:00 2001 From: Ivan Shcherbak Date: Wed, 18 Feb 2026 20:02:28 +0200 Subject: [PATCH 13/17] feat(other): LOCAL-1444 delivery translation (#2886) Co-authored-by: funivan --- core/messages/da.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/de.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/es-419.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es-AR.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es-CL.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es-CO.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es-LA.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es-MX.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es-PE.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/es.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/fr.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/it.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/ja.json | 124 ++++++++++++++++++++++++++++++++----- core/messages/nl.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/no.json | 126 +++++++++++++++++++++++++++++++++----- core/messages/pl.json | 114 ++++++++++++++++++++++++++++++---- core/messages/pt-BR.json | 114 ++++++++++++++++++++++++++++++---- core/messages/pt.json | 114 ++++++++++++++++++++++++++++++---- core/messages/sv.json | 126 +++++++++++++++++++++++++++++++++----- 19 files changed, 2045 insertions(+), 297 deletions(-) diff --git a/core/messages/da.json b/core/messages/da.json index 33755c849..0f571c362 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -43,7 +43,17 @@ "newPassword": "Den nye adgangskode", "confirmPassword": "Bekræft adgangskode", "passwordUpdated": "Adgangskoden er blevet opdateret.", - "somethingWentWrong": "Noget gik galt. Prøv igen senere." + "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "FieldErrors": { + "passwordRequired": "Adgangskode er påkrævet", + "passwordTooSmall": "Adgangskoden skal være på mindst {minLength, plural, =1 {1 character} other {# characters}}", + "passwordLowercaseRequired": "Adgangskoden skal indeholde mindst ét lille bogstav", + "passwordUppercaseRequired": "Adgangskoden skal indeholde mindst ét stort bogstav", + "passwordNumberRequired": "Adgangskoden skal indeholde mindst {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Adgangskoden skal indeholde mindst ét specialtegn", + "passwordsMustMatch": "Adgangskoderne stemmer ikke overens", + "confirmPasswordRequired": "Bekræft din adgangskode" + } }, "Login": { "title": "Log på", @@ -56,6 +66,12 @@ "somethingWentWrong": "Noget gik galt. Prøv igen senere.", "passwordResetRequired": "Nulstilling af adgangskode påkrævet. Kontrollér din e-mail for instruktioner til at nulstille din adgangskode.", "invalidToken": "Dit login-link er ugyldigt eller udløbet. Prøv at logge ind igen.", + "FieldErrors": { + "emailRequired": "E-mail er påkrævet", + "emailInvalid": "Indtast en gyldig e-mailadresse", + "passwordRequired": "Adgangskode er påkrævet", + "invalidInput": "Tjek din indtastning, og prøv igen." + }, "CreateAccount": { "title": "Ny kunde?", "accountBenefits": "Opret en konto hos os, og du vil kunne:", @@ -70,14 +86,36 @@ "title": "Glemt adgangskode", "subtitle": "Indtast den e-mail, der er knyttet til din konto, nedenfor. Vi sender dig instruktioner til nulstilling af din adgangskode.", "confirmResetPassword": "Hvis e-mailadressen {email} er knyttet til en konto i vores butik, har vi sendt dig en e-mail til nulstilling af adgangskode. Tjek din indbakke og spam-mappe, hvis du ikke kan se den.", - "somethingWentWrong": "Noget gik galt. Prøv igen senere." + "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "FieldErrors": { + "emailRequired": "E-mail er påkrævet", + "emailInvalid": "Indtast en gyldig e-mailadresse" + } } }, "Register": { "title": "Registrer konto", "heading": "Ny konto", "cta": "Opret konto", - "somethingWentWrong": "Noget gik galt. Prøv igen senere." + "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "FieldErrors": { + "firstNameRequired": "Fornavnet er påkrævet", + "lastNameRequired": "Efternavnet er påkrævet", + "emailRequired": "E-mail er påkrævet", + "emailInvalid": "Indtast en gyldig e-mailadresse", + "passwordRequired": "Adgangskode er påkrævet", + "passwordTooSmall": "Adgangskoden skal være på mindst {minLength, plural, =1 {1 character} other {# characters}}", + "passwordLowercaseRequired": "Adgangskoden skal indeholde mindst ét lille bogstav", + "passwordUppercaseRequired": "Adgangskoden skal indeholde mindst ét stort bogstav", + "passwordNumberRequired": "Adgangskoden skal indeholde mindst {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Adgangskoden skal indeholde mindst ét specialtegn", + "passwordsMustMatch": "Adgangskoderne stemmer ikke overens", + "addressLine1Required": "Adresselinje 1 er påkrævet", + "cityRequired": "Byen er påkrævet", + "countryRequired": "Landet er påkrævet", + "stateRequired": "Stat/region er påkrævet", + "postalCodeRequired": "Postnummeret er påkrævet" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Noget gik galt. Prøv igen senere.", "EmptyState": { "title": "Du har ingen adresser" + }, + "FieldErrors": { + "firstNameRequired": "Fornavnet er påkrævet", + "lastNameRequired": "Efternavnet er påkrævet", + "addressLine1Required": "Adresselinje 1 er påkrævet", + "cityRequired": "Byen er påkrævet", + "countryRequired": "Landet er påkrævet", + "stateRequired": "Stat/region er påkrævet", + "postalCodeRequired": "Postnummeret er påkrævet" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Abonner på vores nyhedsbrev.", "marketingPreferencesUpdated": "Markedsføringspræferencerne er blevet opdateret!", "somethingWentWrong": "Noget gik galt. Prøv igen senere." + }, + "FieldErrors": { + "firstNameRequired": "Fornavnet er påkrævet", + "firstNameTooSmall": "Fornavnet skal være på mindst 2 tegn", + "lastNameRequired": "Efternavnet er påkrævet", + "lastNameTooSmall": "Efternavnet skal være på mindst 2 tegn", + "emailRequired": "E-mail er påkrævet", + "emailInvalid": "Indtast en gyldig e-mailadresse", + "currentPasswordRequired": "Aktuel adgangskode er påkrævet", + "passwordRequired": "Adgangskode er påkrævet", + "passwordTooSmall": "Adgangskoden skal være på mindst {minLength, plural, =1 {1 character} other {# characters}}", + "passwordLowercaseRequired": "Adgangskoden skal indeholde mindst ét lille bogstav", + "passwordUppercaseRequired": "Adgangskoden skal indeholde mindst ét stort bogstav", + "passwordNumberRequired": "Adgangskoden skal indeholde mindst {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Adgangskoden skal indeholde mindst ét specialtegn", + "passwordsMustMatch": "Adgangskoderne stemmer ikke overens", + "confirmPasswordRequired": "Bekræft din adgangskode" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Vi bemærkede, at du havde gemt varer i en tidligere indkøbskurv, så vi har føjet dem til din nuværende indkøbskurv for dig.", "cartRestored": "Du startede en indkøbskurv på en anden enhed, og vi har gendannet den her, så du kan fortsætte, hvor du slap.", "cartUpdateInProgress": "Du har en igangværende opdatering af din indkøbskurv. Er du sikker på, at du vil forlade denne side? Dine ændringer kan gå tabt.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "Den oprindelige pris var {price}.", + "currentPrice": "Den aktuelle pris er {price}.", + "quantityReadyToShip": "{quantity, number} klar til forsendelse", + "quantityOnBackorder": "{antal, number} vil være i restordre", + "partiallyAvailable": "Kun {quantity, number} tilgængelig(e)", "CheckoutSummary": { "title": "Oversigt", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Opdater forsendelse", "addShipping": "Tilføj forsendelse", "cartNotFound": "Der opstod en fejl ved hentning af din indkøbskurv", - "noShippingOptions": "Der er ingen tilgængelige forsendelsesmuligheder for din adresse" + "noShippingOptions": "Der er ingen tilgængelige forsendelsesmuligheder for din adresse", + "countryRequired": "Landet er påkrævet" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Yderligere oplysninger", "currentStock": "{antal, number} på lager", "backorderQuantity": "{antal, number} vil være i restordre", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Føj til kurv", "outOfStock": "Udsolgt", @@ -427,13 +497,24 @@ "button": "Skriv en anmeldelse", "title": "Skriv en anmeldelse", "submit": "Indsend", + "cancel": "Annuller", "ratingLabel": "Bedømmelse", "titleLabel": "Titel", "reviewLabel": "Anmeldelse", "nameLabel": "Navn", "emailLabel": "E-mail", "successMessage": "Din anmeldelse er blevet indsendt!", - "somethingWentWrong": "Noget gik galt. Prøv igen senere." + "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "FieldErrors": { + "titleRequired": "Titel er påkrævet", + "authorRequired": "Navn er påkrævet", + "emailRequired": "E-mail er påkrævet", + "emailInvalid": "Indtast en gyldig e-mailadresse", + "textRequired": "Anmeldelse er påkrævet", + "ratingRequired": "Bedømmelse er påkrævet", + "ratingTooSmall": "Bedømmelsen skal være mindst 1", + "ratingTooLarge": "Bedømmelsen må højst være 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Hold dig opdateret med de seneste nyheder og tilbud fra vores butik.", "subscribedToNewsletter": "Du er blevet tilmeldt vores nyhedsbrev!", "Errors": { - "invalidEmail": "Indtast en gyldig e-mailadresse.", + "emailRequired": "E-mail er påkrævet", + "invalidEmail": "Indtast en gyldig e-mailadresse", "somethingWentWrong": "Noget gik galt. Prøv igen senere." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "Den oprindelige pris var {price}.", + "currentPrice": "Den aktuelle pris er {price}.", + "range": "Pris fra {minValue} til {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Tjek saldo", "description": "Du kan tjekke saldoen og få oplysninger om dit gavekort ved at indtaste koden i feltet nedenfor.", "inputLabel": "Kode", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Købt", "senderLabel": "Fra", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Jeg accepterer, at dette gavekort udløber den {expiryDate}", "ctaLabel": "Føj til kurv", "Errors": { - "amountRequired": "Vælg eller indtast et gavekortbeløb.", - "amountInvalid": "Vælg et gyldigt gavekortbeløb.", - "amountOutOfRange": "Indtast et beløb mellem {minAmount} og {maxAmount}.", - "unexpectedSettingsError": "Der opstod en uventet fejl under hentning af indstillingerne for gavekort. Prøv igen senere." + "amountRequired": "Vælg eller indtast et gavekortbeløb", + "amountInvalid": "Vælg et gyldigt gavekortbeløb", + "amountOutOfRange": "Indtast et beløb mellem {minAmount} og {maxAmount}", + "unexpectedSettingsError": "Der opstod en uventet fejl under hentning af indstillingerne for gavekort. Prøv igen senere.", + "senderNameRequired": "Dit navn er påkrævet", + "senderEmailRequired": "Din e-mailadresse er påkrævet", + "recipientNameRequired": "Modtagerens navn er påkrævet", + "recipientEmailRequired": "Modtagerens e-mailadresse er påkrævet", + "emailInvalid": "Indtast en gyldig e-mailadresse", + "checkboxRequired": "Du skal sætte kryds i dette felt for at fortsætte" } } } }, "Form": { - "optional": "Valgfrit" + "optional": "Valgfrit", + "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 317deae02..8a7db06ad 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -43,7 +43,17 @@ "newPassword": "Neues Passwort", "confirmPassword": "Passwort bestätigen", "passwordUpdated": "Passwort wurde erfolgreich aktualisiert!", - "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." + "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "FieldErrors": { + "passwordRequired": "Es muss ein Passwort angegeben werden", + "passwordTooSmall": "Das Passwort muss mindestens {minLength, plural, =1 {1 Zeichen} other {# Zeichen}} lang sein", + "passwordLowercaseRequired": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten", + "passwordUppercaseRequired": "Das Passwort muss mindestens einen Großbuchstaben enthalten", + "passwordNumberRequired": "Das Passwort muss mindestens {minNumbers, plural, =1 {eine Zahl} other {# Zahlen}} enthalten", + "passwordSpecialCharacterRequired": "Das Passwort muss mindestens ein Sonderzeichen enthalten", + "passwordsMustMatch": "Die Passwörter stimmen nicht überein", + "confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort" + } }, "Login": { "title": "Anmelden", @@ -56,6 +66,12 @@ "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", "passwordResetRequired": "Passwortzurücksetzung erforderlich. Bitte überprüfen Sie Ihre E-Mails für die Anleitung zum Zurücksetzen Ihres Passworts.", "invalidToken": "Ihr Anmeldelink ist ungültig oder abgelaufen. Bitte versuchen Sie erneut, sich anzumelden.", + "FieldErrors": { + "emailRequired": "Die E-Mail-Adresse muss angegeben werden", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse an", + "passwordRequired": "Es muss ein Passwort angegeben werden", + "invalidInput": "Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es erneut" + }, "CreateAccount": { "title": "Neuer Kunde?", "accountBenefits": "Wenn Sie ein Konto bei uns erstellen, haben Sie folgende Möglichkeiten:", @@ -70,14 +86,36 @@ "title": "Passwort vergessen", "subtitle": "Geben Sie unten die mit Ihrem Konto verknüpfte E-Mail-Adresse ein. Wir senden Ihnen Anweisungen zum Zurücksetzen Ihres Passworts.", "confirmResetPassword": "Wenn die E-Mail-Adresse {email} mit einem Konto in unserem Geschäft verknüpft ist, haben wir Ihnen eine E-Mail zum Zurücksetzen des Passworts gesendet. Bitte überprüfen Sie Ihren Posteingang und Spam-Ordner, wenn Sie sie nicht sehen.", - "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." + "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "FieldErrors": { + "emailRequired": "Die E-Mail-Adresse muss angegeben werden", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse an" + } } }, "Register": { "title": "Konto registrieren", "heading": "Neues Konto", "cta": "Konto erstellen", - "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." + "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "FieldErrors": { + "firstNameRequired": "Es muss ein Vorname angegeben werden", + "lastNameRequired": "Es muss ein Nachname angegeben werden", + "emailRequired": "Die E-Mail-Adresse muss angegeben werden", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse an", + "passwordRequired": "Es muss ein Passwort angegeben werden", + "passwordTooSmall": "Das Passwort muss mindestens {minLength, plural, =1 {1 Zeichen} other {# Zeichen}} lang sein", + "passwordLowercaseRequired": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten", + "passwordUppercaseRequired": "Das Passwort muss mindestens einen Großbuchstaben enthalten", + "passwordNumberRequired": "Das Passwort muss mindestens {minNumbers, plural, =1 {eine Zahl} other {# Zahlen}} enthalten", + "passwordSpecialCharacterRequired": "Das Passwort muss mindestens ein Sonderzeichen enthalten", + "passwordsMustMatch": "Die Passwörter stimmen nicht überein", + "addressLine1Required": "Adresszeile 1 ist eine Pflichtangabe", + "cityRequired": "Ort ist eine Pflichtangabe", + "countryRequired": "Land ist eine Pflichtangabe", + "stateRequired": "Bundesstaat/Provinz ist eine Pflichtangabe", + "postalCodeRequired": "Postleitzahl ist erforderlich" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", "EmptyState": { "title": "Sie haben keine Adressen" + }, + "FieldErrors": { + "firstNameRequired": "Es muss ein Vorname angegeben werden", + "lastNameRequired": "Es muss ein Nachname angegeben werden", + "addressLine1Required": "Adresszeile 1 ist eine Pflichtangabe", + "cityRequired": "Ort ist eine Pflichtangabe", + "countryRequired": "Land ist eine Pflichtangabe", + "stateRequired": "Bundesstaat/Provinz ist eine Pflichtangabe", + "postalCodeRequired": "Postleitzahl ist erforderlich" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Abonnieren Sie unseren Newsletter.", "marketingPreferencesUpdated": "Marketingeinstellungen wurden erfolgreich aktualisiert!", "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." + }, + "FieldErrors": { + "firstNameRequired": "Es muss ein Vorname angegeben werden", + "firstNameTooSmall": "„Vorname“ muss mindestens 2 Zeichen lang sei", + "lastNameRequired": "Es muss ein Nachname angegeben werden", + "lastNameTooSmall": "„Nachname“ muss mindestens 2 Zeichen lang sein", + "emailRequired": "Die E-Mail-Adresse muss angegeben werden", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse an", + "currentPasswordRequired": "Aktuelles Passwort ist erforderlich", + "passwordRequired": "Es muss ein Passwort angegeben werden", + "passwordTooSmall": "Das Passwort muss mindestens {minLength, plural, =1 {1 Zeichen} other {# Zeichen}} lang sein", + "passwordLowercaseRequired": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten", + "passwordUppercaseRequired": "Das Passwort muss mindestens einen Großbuchstaben enthalten", + "passwordNumberRequired": "Das Passwort muss mindestens {minNumbers, plural, =1 {eine Zahl} other {# Zahlen}} enthalten", + "passwordSpecialCharacterRequired": "Das Passwort muss mindestens ein Sonderzeichen enthalten", + "passwordsMustMatch": "Die Passwörter stimmen nicht überein", + "confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Wir haben festgestellt, dass Sie Artikel in einem früheren Warenkorb gespeichert hatten, also haben wir diese Ihrem aktuellen Warenkorb hinzugefügt.", "cartRestored": "Sie haben einen Warenkorb auf einem anderen Gerät angelegt, und wir haben ihn hier wiederhergestellt, damit Sie dort weitermachen können, wo Sie aufgehört haben.", "cartUpdateInProgress": "Sie führen derzeit eine Aktualisierung Ihres Warenkorbs durch. Sind Sie sicher, dass Sie diese Seite verlassen möchten? Ihre Änderungen könnten verloren gehen.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "Der ursprüngliche Preis war {price}.", + "currentPrice": "Der aktuelle Preis beträgt {price}.", + "quantityReadyToShip": "{quantity, number} bereit zum Versand", + "quantityOnBackorder": "{quantity, number} wird nachbestellt", + "partiallyAvailable": "Nur {quantity, number} verfügbar", "CheckoutSummary": { "title": "Übersicht", "subTotal": "Zwischensumme", @@ -335,7 +402,8 @@ "updateShipping": "Versand aktualisieren", "addShipping": "Versand hinzufügen", "cartNotFound": "Beim Abrufen des Warenkorbs ist ein Fehler aufgetreten", - "noShippingOptions": "Für Ihre Adresse sind keine Versandoptionen verfügbar" + "noShippingOptions": "Für Ihre Adresse sind keine Versandoptionen verfügbar", + "countryRequired": "Land ist eine Pflichtangabe" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Weitere Informationen", "currentStock": "{quantity, number} auf Lager", "backorderQuantity": "{quantity, number} wird nachbestellt", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Zum Warenkorb hinzufügen", "outOfStock": "Kein Lagerbestand", @@ -427,13 +497,24 @@ "button": "Eine Bewertung schreiben", "title": "Eine Bewertung schreiben", "submit": "Einreichen", + "cancel": "Abbrechen", "ratingLabel": "Bewertung", "titleLabel": "Titel", "reviewLabel": "Bewertung", "nameLabel": "Name", "emailLabel": "E-Mail", "successMessage": "Ihre Bewertung wurde erfolgreich übermittelt!", - "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." + "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "FieldErrors": { + "titleRequired": "Titel ist erforderlich", + "authorRequired": "Es muss ein Name angegeben werden", + "emailRequired": "Die E-Mail-Adresse muss angegeben werden", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse an", + "textRequired": "Überprüfung ist erforderlich", + "ratingRequired": "Bewertung ist erforderlich.", + "ratingTooSmall": "Die Bewertung muss mindestens 1 betragen", + "ratingTooLarge": "Die Bewertung darf höchstens 5 betragen" + } } } }, @@ -515,7 +596,8 @@ "description": "Bleiben Sie auf dem Laufenden über die aktuellen Neuigkeiten und Angebote in unserem Shop.", "subscribedToNewsletter": "Sie haben unseren Newsletter abonniert!", "Errors": { - "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse an.", + "emailRequired": "Die E-Mail-Adresse muss angegeben werden", + "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse an", "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "Der ursprüngliche Preis war {price}.", + "currentPrice": "Der aktuelle Preis beträgt {price}.", + "range": "Preis von {minValue} bis {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Guthaben abfragen", "description": "Sie können das Guthaben überprüfen und Informationen zu Ihrem Geschenkgutschein erhalten, indem Sie den Code in das Feld unten eingeben.", "inputLabel": "Code", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Gekauft", "senderLabel": "Von", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Ich bestätige, dass dieser Geschenkgutschein am {expiryDate} abläuft.", "ctaLabel": "Zum Warenkorb hinzufügen", "Errors": { - "amountRequired": "Bitte wählen Sie einen Geschenkgutscheinbetrag aus oder geben Sie ihn ein.", - "amountInvalid": "Bitte wählen Sie einen gültigen Betrag für diesen Geschenkgutschein aus.", - "amountOutOfRange": "Bitte geben Sie einen Betrag zwischen {minAmount} und {maxAmount} ein.", - "unexpectedSettingsError": "Beim Abrufen der Geschenkgutscheineinstellungen ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut." + "amountRequired": "Bitte wählen Sie einen Geschenkgutscheinbetrag aus oder geben Sie ihn ein", + "amountInvalid": "Bitte wählen Sie einen gültigen Betrag für diesen Geschenkgutschein aus", + "amountOutOfRange": "Bitte geben Sie einen Betrag zwischen {minAmount} und {maxAmount} ein", + "unexpectedSettingsError": "Beim Abrufen der Geschenkgutscheineinstellungen ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut.", + "senderNameRequired": "Ihr Name ist erforderlich", + "senderEmailRequired": "Ihre E-Mail-Adresse ist erforderlich", + "recipientNameRequired": "Der Name des Empfängers ist erforderlich", + "recipientEmailRequired": "Die E-Mail-Adresse des Empfängers ist erforderlich", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse an", + "checkboxRequired": "Sie müssen dieses Feld ankreuzen, um fortzufahren" } } } }, "Form": { - "optional": "optional" + "optional": "optional", + "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/es-419.json b/core/messages/es-419.json index f98234073..93e494019 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 f98234073..93e494019 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 f98234073..93e494019 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 f98234073..93e494019 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 f98234073..93e494019 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 f98234073..93e494019 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 f98234073..93e494019 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "¡La contraseña se actualizó correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" + } }, "Login": { "title": "Inicio de sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Se requiere restablecimiento de contraseña. Por favor, revisa tu email para ver instrucciones para restablecer tu contraseña.", "invalidToken": "Tu enlace de acceso es inválido o caducó. Por favor, intenta iniciar sesión de nuevo.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo." + }, "CreateAccount": { "title": "¿Nuevo cliente?", "accountBenefits": "Crea una cuenta con nosotros y podrás:", @@ -70,14 +86,36 @@ "title": "Olvidé la contraseña", "subtitle": "A continuación, ingresa el correo electrónico asociado a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Revisa tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido es obligatorio.", + "addressLine1Required": "La línea 1 es obligatoria", + "cityRequired": "La ciudad es obligatoria", + "countryRequired": "País es un campo obligatorio", + "stateRequired": "Estado/Provincia es un campo obligatorio.", + "postalCodeRequired": "El campo Código postal es obligatorio." } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbete a nuestro boletín informativo.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se actualizaron con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre de pila debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "currentPasswordRequired": "La contraseña actual es obligatoria", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe ser al menos {minLength, plural, =1 {1 carácter} other {# caracteres}} longitud", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Por favor, confirme tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Notamos que tenías artículos guardados en un carrito anterior, así que los hemos agregado a tu carrito actual.", "cartRestored": "Iniciaste un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas continuar desde donde lo dejaste.", "cartUpdateInProgress": "Tiene una actualización de carrito en proceso. ¿Estás seguro de que quieres salir de esta página? Es posible que se pierdan sus cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{cantidad, número} listo para enviar", + "quantityOnBackorder": "{cantidad, número} estará en pedido pendiente", + "partiallyAvailable": "Solo {cantidad, número} disponible", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Agregar envío", "cartNotFound": "Se produjo un error al recuperar su carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "País es un campo obligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", @@ -427,13 +497,24 @@ "button": "Escribe una opinión", "title": "Escribe una opinión", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Revisar", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "Se requiere título", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "El campo de correo electrónico es obligatorio", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "textRequired": "Es necesario revisar", + "ratingRequired": "Se requiere una calificación", + "ratingTooSmall": "La puntuación debe ser al menos 1", + "ratingTooLarge": "El puntaje debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Mantente al día con las últimas noticias y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te suscribiste a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "El campo de correo electrónico es obligatorio", + "invalidEmail": "Por favor, introduzca una dirección de email válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precio de {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este Certificado de Regalo caducará el {expiryDate}", "ctaLabel": "Agregar al carrito", "Errors": { - "amountRequired": "Seleccione o ingrese el monto de un certificado de regalo.", - "amountInvalid": "Seleccione un monto de certificado de regalo válido.", - "amountOutOfRange": "Ingrese una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde." + "amountRequired": "Por favor, selecciona o introduce el importe de un vale regalo", + "amountInvalid": "Por favor, seleccione un importe válido de un certificado regalo", + "amountOutOfRange": "Por favor, introduzca una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se produjo un error inesperado al recuperar la configuración del certificado de regalo. Intente de nuevo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "Se requiere el nombre del destinatario", + "recipientEmailRequired": "Se requiere el email del destinatario", + "emailInvalid": "Por favor, introduzca una dirección de email válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 c21c783f2..507e02c4b 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -43,7 +43,17 @@ "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña", "passwordUpdated": "La contraseña se ha actualizado correctamente.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe tener al menos {minLength, plural, =1 {1 carácter} other {# caracteres}}", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Confirma tu contraseña" + } }, "Login": { "title": "Iniciar sesión", @@ -56,6 +66,12 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "passwordResetRequired": "Es necesario restablecer la contraseña. Consulta tu correo electrónico para recibir instrucciones sobre cómo restablecer tu contraseña.", "invalidToken": "Tu enlace de inicio de sesión no es válido o ha caducado. Intenta conectarte de nuevo.", + "FieldErrors": { + "emailRequired": "Se requiere un correo electrónico", + "emailInvalid": "Introduce una dirección de correo electrónico válida", + "passwordRequired": "Se requiere la contraseña", + "invalidInput": "Comprueba lo que has escrito e inténtalo de nuevo." + }, "CreateAccount": { "title": "¿Cliente nuevo?", "accountBenefits": "Cree una cuenta con nosotros y podrá:", @@ -70,14 +86,36 @@ "title": "Olvidé mi contraseña", "subtitle": "Introduce a continuación la dirección de correo electrónico asociada a tu cuenta. Te enviaremos instrucciones para restablecer tu contraseña.", "confirmResetPassword": "Si la dirección de correo electrónico {email} está vinculada a una cuenta en nuestra tienda, te hemos enviado un correo electrónico de restablecimiento de contraseña. Comprueba tu bandeja de entrada y tu carpeta de correo no deseado si no lo ves.", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "emailRequired": "Se requiere un correo electrónico", + "emailInvalid": "Introduce una dirección de correo electrónico válida" + } } }, "Register": { "title": "Registrar cuenta", "heading": "Cuenta nueva", "cta": "Crear cuenta", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido(s) es obligatorio.", + "emailRequired": "Se requiere un correo electrónico", + "emailInvalid": "Introduce una dirección de correo electrónico válida", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe tener al menos {minLength, plural, =1 {1 carácter} other {# caracteres}}", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "addressLine1Required": "El campo Línea de dirección 1 es obligatorio", + "cityRequired": "El campo Ciudad es obligatorio", + "countryRequired": "El campo de país es obligatorio.", + "stateRequired": "El campo de Estado/Provincia es obligatorio", + "postalCodeRequired": "El campo Código postal es obligatorio" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", "EmptyState": { "title": "No tienes ninguna dirección" + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "lastNameRequired": "El campo Apellido(s) es obligatorio.", + "addressLine1Required": "El campo Línea de dirección 1 es obligatorio", + "cityRequired": "El campo Ciudad es obligatorio", + "countryRequired": "El campo de país es obligatorio.", + "stateRequired": "El campo de Estado/Provincia es obligatorio", + "postalCodeRequired": "El campo Código postal es obligatorio" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Suscríbase a nuestro boletín.", "marketingPreferencesUpdated": "¡Las preferencias de marketing se han actualizado correctamente!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + }, + "FieldErrors": { + "firstNameRequired": "El campo Nombre es obligatorio.", + "firstNameTooSmall": "El nombre debe tener al menos 2 caracteres", + "lastNameRequired": "El campo Apellido(s) es obligatorio.", + "lastNameTooSmall": "El apellido debe tener al menos 2 caracteres", + "emailRequired": "Se requiere un correo electrónico", + "emailInvalid": "Introduce una dirección de correo electrónico válida", + "currentPasswordRequired": "Es obligatorio indicar la contraseña actual", + "passwordRequired": "Se requiere la contraseña", + "passwordTooSmall": "La contraseña debe tener al menos {minLength, plural, =1 {1 carácter} other {# caracteres}}", + "passwordLowercaseRequired": "La contraseña debe contener al menos una letra minúscula", + "passwordUppercaseRequired": "La contraseña debe contener al menos una letra mayúscula", + "passwordNumberRequired": "La contraseña debe contener al menos {minNumbers, plural, =1 {un número} other {# números}}", + "passwordSpecialCharacterRequired": "La contraseña debe contener al menos un carácter especial", + "passwordsMustMatch": "Las contraseñas no coinciden", + "confirmPasswordRequired": "Confirma tu contraseña" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Nos hemos dado cuenta de que tenías artículos guardados en un carrito anterior, así que los hemos añadido a tu carrito actual.", "cartRestored": "Empezaste a llenar un carrito en otro dispositivo y lo hemos restaurado aquí para que puedas seguir donde lo dejaste.", "cartUpdateInProgress": "Tienes una actualización del carrito en curso. ¿Seguro que quieres abandonar esta página? Es posible que se pierdan los cambios.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "quantityReadyToShip": "{quantity, number} listo para enviar", + "quantityOnBackorder": "Cantidad que se añadirá a pedidos pendientes: {quantity, number}", + "partiallyAvailable": "Cantidad disponible: {quantity, number}", "CheckoutSummary": { "title": "Resumen", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Actualizar envío", "addShipping": "Añadir envío", "cartNotFound": "Se ha producido un error al recuperar tu carrito", - "noShippingOptions": "No hay opciones de envío disponibles para tu dirección" + "noShippingOptions": "No hay opciones de envío disponibles para tu dirección", + "countryRequired": "El campo de país es obligatorio." } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en existencias", "backorderQuantity": "{cantidad, número} en pedidos pendientes", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Añadir al carrito", "outOfStock": "Sin existencias", @@ -427,13 +497,24 @@ "button": "Escribir una reseña", "title": "Escribir una reseña", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Calificación", "titleLabel": "Título", "reviewLabel": "Reseña", "nameLabel": "Nombre", "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña se ha enviado correctamente!", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "FieldErrors": { + "titleRequired": "El título es obligatorio", + "authorRequired": "El campo Nombre es obligatorio", + "emailRequired": "Se requiere un correo electrónico", + "emailInvalid": "Introduce una dirección de correo electrónico válida", + "textRequired": "La revisión es obligatoria", + "ratingRequired": "La calificación es obligatoria", + "ratingTooSmall": "La puntuación debe ser como mínimo 1", + "ratingTooLarge": "La puntuación debe ser como máximo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Entérate de todas las novedades y ofertas de nuestra tienda.", "subscribedToNewsletter": "¡Te has suscrito a nuestro boletín!", "Errors": { - "invalidEmail": "Ingrese una dirección de correo electrónico válida.", + "emailRequired": "Se requiere un correo electrónico", + "invalidEmail": "Introduce una dirección de correo electrónico válida", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "El precio original era {price}.", + "currentPrice": "El precio actual es {price}.", + "range": "Precios entre {minValue} y {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Verificar saldo", "description": "Puedes comprobar el saldo y obtener la información sobre tu cupón de regalo escribiendo el código en la casilla de abajo.", "inputLabel": "Código", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Comprado", "senderLabel": "de", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Reconozco que este vale de regalo caducará el {expiryDate}", "ctaLabel": "Añadir al carrito", "Errors": { - "amountRequired": "Selecciona o introduce el importe de un certificado de regalo.", - "amountInvalid": "Selecciona un importe de certificado de regalo válido.", - "amountOutOfRange": "Introduce una cantidad entre {minAmount} y {maxAmount}.", - "unexpectedSettingsError": "Se ha producido un error inesperado al recuperar la configuración del cupón de regalo. Vuelve a intentarlo más tarde." + "amountRequired": "Selecciona o introduce un importe para el cupón de regalo", + "amountInvalid": "Selecciona un importe válido para el cupón de regalo", + "amountOutOfRange": "Introduce una cantidad entre {minAmount} y {maxAmount}", + "unexpectedSettingsError": "Se ha producido un error inesperado al recuperar la configuración del cupón de regalo. Vuelve a intentarlo más tarde.", + "senderNameRequired": "Tu nombre es obligatorio", + "senderEmailRequired": "Tu correo electrónico es obligatorio", + "recipientNameRequired": "El nombre del destinatario es obligatorio", + "recipientEmailRequired": "El correo electrónico del destinatario es obligatorio", + "emailInvalid": "Introduce una dirección de correo electrónico válida", + "checkboxRequired": "Debes marcar esta casilla para continuar" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 cc1171ace..9e3ba28a7 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -43,7 +43,17 @@ "newPassword": "Nouveau mot de passe", "confirmPassword": "Confirmer le mot de passe", "passwordUpdated": "Le mot de passe a bien été mis à jour !", - "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." + "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "FieldErrors": { + "passwordRequired": "Un mot de passe est requis", + "passwordTooSmall": "Le mot de passe doit comporter au moins {minLength, plural, =1 {1 character} other {# characters}} caractères", + "passwordLowercaseRequired": "Le mot de passe doit comporter au moins une lettre minuscule", + "passwordUppercaseRequired": "Le mot de passe doit comporter au moins une lettre majuscule", + "passwordNumberRequired": "Le mot de passe doit comporter au moins {minNumbers, plural, =1 {one number} other {# numbers}} chiffres", + "passwordSpecialCharacterRequired": "Le mot de passe doit comporter au moins un caractère spécial", + "passwordsMustMatch": "Les mots de passe ne correspondent pas", + "confirmPasswordRequired": "Veuillez confirmer votre mot de passe" + } }, "Login": { "title": "Connexion", @@ -56,6 +66,12 @@ "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", "passwordResetRequired": "Réinitialisation du mot de passe requise. Veuillez consulter votre e-mail pour les instructions de réinitialisation de votre mot de passe.", "invalidToken": "Votre lien de connexion n'est pas valide ou a expiré. Veuillez réessayer de vous connecter.", + "FieldErrors": { + "emailRequired": "L'adresse e-mail est requise", + "emailInvalid": "Veuillez saisir une adresse e-mail valide", + "passwordRequired": "Un mot de passe est requis", + "invalidInput": "Veuillez vérifier votre saisie et réessayer." + }, "CreateAccount": { "title": "Nouveau client ?", "accountBenefits": "Créez un compte sur notre site et vous pourrez :", @@ -70,14 +86,36 @@ "title": "Mot de passe oublié", "subtitle": "Veuillez saisir ci-dessous l'adresse e-mail associée à votre compte. Nous vous enverrons des instructions pour réinitialiser votre mot de passe.", "confirmResetPassword": "Si l'adresse e-mail {email} est liée à un compte dans notre boutique, vous recevrez un e-mail pour réinitialiser votre mot de passe. Veuillez consulter votre boîte de réception, ainsi que votre dossier de courrier indésirable si vous ne trouvez pas cet e-mail.", - "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." + "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "FieldErrors": { + "emailRequired": "L'adresse e-mail est requise", + "emailInvalid": "Veuillez saisir une adresse e-mail valide" + } } }, "Register": { "title": "Enregistrer un compte", "heading": "Nouveau compte", "cta": "Créer un compte", - "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." + "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "FieldErrors": { + "firstNameRequired": "Le prénom est requis", + "lastNameRequired": "Le nom de famille est requis", + "emailRequired": "L'adresse e-mail est requise", + "emailInvalid": "Veuillez saisir une adresse e-mail valide", + "passwordRequired": "Un mot de passe est requis", + "passwordTooSmall": "Le mot de passe doit comporter au moins {minLength, plural, =1 {1 character} other {# characters}} caractères", + "passwordLowercaseRequired": "Le mot de passe doit comporter au moins une lettre minuscule", + "passwordUppercaseRequired": "Le mot de passe doit comporter au moins une lettre majuscule", + "passwordNumberRequired": "Le mot de passe doit comporter au moins {minNumbers, plural, =1 {one number} other {# numbers}} chiffres", + "passwordSpecialCharacterRequired": "Le mot de passe doit comporter au moins un caractère spécial", + "passwordsMustMatch": "Les mots de passe ne correspondent pas", + "addressLine1Required": "Ligne d'adresse 1 est requise", + "cityRequired": "La ville est requise", + "countryRequired": "Le pays est requis", + "stateRequired": "État/province est requis", + "postalCodeRequired": "Le code postal est requis" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", "EmptyState": { "title": "Vous n'avez pas d'adresse" + }, + "FieldErrors": { + "firstNameRequired": "Le prénom est requis", + "lastNameRequired": "Le nom de famille est requis", + "addressLine1Required": "Ligne d'adresse 1 est requise", + "cityRequired": "La ville est requise", + "countryRequired": "Le pays est requis", + "stateRequired": "État/province est requis", + "postalCodeRequired": "Le code postal est requis" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Abonnez-vous à notre newsletter.", "marketingPreferencesUpdated": "Les préférences marketing ont bien été mises à jour.", "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." + }, + "FieldErrors": { + "firstNameRequired": "Le prénom est requis", + "firstNameTooSmall": "Le prénom doit comporter au moins 2 caractères", + "lastNameRequired": "Le nom de famille est requis", + "lastNameTooSmall": "Le nom de famille doit comporter au moins 2 caractères", + "emailRequired": "L'adresse e-mail est requise", + "emailInvalid": "Veuillez saisir une adresse e-mail valide", + "currentPasswordRequired": "Vous devez saisir le mot de passe actuel", + "passwordRequired": "Un mot de passe est requis", + "passwordTooSmall": "Le mot de passe doit comporter au moins {minLength, plural, =1 {1 character} other {# characters}} caractères", + "passwordLowercaseRequired": "Le mot de passe doit comporter au moins une lettre minuscule", + "passwordUppercaseRequired": "Le mot de passe doit comporter au moins une lettre majuscule", + "passwordNumberRequired": "Le mot de passe doit comporter au moins {minNumbers, plural, =1 {one number} other {# numbers}} chiffres", + "passwordSpecialCharacterRequired": "Le mot de passe doit comporter au moins un caractère spécial", + "passwordsMustMatch": "Les mots de passe ne correspondent pas", + "confirmPasswordRequired": "Veuillez confirmer votre mot de passe" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Nous avons remarqué que votre panier précédent contenait des articles enregistrés. Nous avons donc ajouté ces articles à votre panier actuel.", "cartRestored": "Vous avez débuté vos achats sur un autre appareil. Nous avons restauré votre panier ici pour que vous puissiez reprendre là où vous en étiez.", "cartUpdateInProgress": "Une mise à jour de votre panier est en cours. Voulez-vous vraiment quitter cette page ? Vos modifications pourraient être perdues.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "Le prix initial était de {price}.", + "currentPrice": "Le prix actuel est de {price}.", + "quantityReadyToShip": "{quantity, number} prêts à être expédiés", + "quantityOnBackorder": "{quantity, number} seront en attente de réapprovisionnement", + "partiallyAvailable": "Seulement {quantity, number} disponibles", "CheckoutSummary": { "title": "Récapitulatif", "subTotal": "Sous-total", @@ -335,7 +402,8 @@ "updateShipping": "Mettre à jour l’expédition", "addShipping": "Ajouter l’expédition", "cartNotFound": "Une erreur s’est produite lors de la récupération de votre panier", - "noShippingOptions": "Aucune option de livraison n'est disponible pour votre adresse" + "noShippingOptions": "Aucune option de livraison n'est disponible pour votre adresse", + "countryRequired": "Le pays est requis" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Informations supplémentaires", "currentStock": "{quantity, number} en stock", "backorderQuantity": "{quantité, nombre} en attente de réapprovisionnement", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Ajouter au panier", "outOfStock": "En rupture de stock", @@ -427,13 +497,24 @@ "button": "Rédiger un avis", "title": "Rédiger un avis", "submit": "Envoyer", + "cancel": "Annuler", "ratingLabel": "Note", "titleLabel": "Titre", "reviewLabel": "Avis", "nameLabel": "Nom", "emailLabel": "E-mail", "successMessage": "Votre avis a bien été envoyé !", - "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." + "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "FieldErrors": { + "titleRequired": "Le titre est obligatoire", + "authorRequired": "Le nom doit être renseigné", + "emailRequired": "L'adresse e-mail est requise", + "emailInvalid": "Veuillez saisir une adresse e-mail valide", + "textRequired": "Une révision est requise", + "ratingRequired": "La note est requise", + "ratingTooSmall": "La note doit être supérieure ou égale à 1", + "ratingTooLarge": "La note doit être au maximum de 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Restez informé des dernières nouvelles et offres de notre magasin.", "subscribedToNewsletter": "Votre abonnement à la newsletter a bien été pris en compte !", "Errors": { - "invalidEmail": "Veuillez saisir une adresse e-mail valide.", + "emailRequired": "L'adresse e-mail est requise", + "invalidEmail": "Veuillez saisir une adresse e-mail valide", "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "Le prix initial était de {price}.", + "currentPrice": "Le prix actuel est de {price}.", + "range": "Prix de {minValue} à {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Vérifier le solde", "description": "Vous pouvez vérifier le solde et obtenir des informations sur votre chèque-cadeau en saisissant le code dans la case ci-dessous.", "inputLabel": "Code", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Acheté(s", "senderLabel": "De", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Je reconnais que ce chèque-cadeau expirera le {expiryDate}", "ctaLabel": "Ajouter au panier", "Errors": { - "amountRequired": "Veuillez sélectionner ou saisir un montant de chèque-cadeau.", - "amountInvalid": "Veuillez sélectionner un montant de chèque-cadeau valide.", - "amountOutOfRange": "Veuillez saisir un montant entre {minAmount} et {maxAmount}.", - "unexpectedSettingsError": "Une erreur inattendue s’est produite lors de la récupération des paramètres du chèque-cadeau. Veuillez réessayer plus tard." + "amountRequired": "Veuillez sélectionner ou saisir un montant de chèque-cadeau", + "amountInvalid": "Veuillez sélectionner un montant de chèque-cadeau valide", + "amountOutOfRange": "Veuillez saisir un montant entre {minAmount} et {maxAmount}", + "unexpectedSettingsError": "Une erreur inattendue s’est produite lors de la récupération des paramètres du chèque-cadeau. Veuillez réessayer plus tard.", + "senderNameRequired": "Votre nom est requis", + "senderEmailRequired": "Votre adresse e-mail est requise", + "recipientNameRequired": "Le nom du destinataire est obligatoire", + "recipientEmailRequired": "L’adresse e-mail du destinataire est obligatoire", + "emailInvalid": "Veuillez saisir une adresse e-mail valide", + "checkboxRequired": "Vous devez cocher cette case pour continuer" } } } }, "Form": { - "optional": "facultatif" + "optional": "facultatif", + "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 3f319e845..9d49e830c 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -43,7 +43,17 @@ "newPassword": "Nuova password", "confirmPassword": "Conferma password", "passwordUpdated": "La password è stata aggiornata!", - "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." + "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "FieldErrors": { + "passwordRequired": "La password è obbligatoria", + "passwordTooSmall": "La password deve essere lunga almeno {minLength, plural, =1 {1 carattere} other {# caratteri}}", + "passwordLowercaseRequired": "La password deve contenere almeno una lettera minuscola", + "passwordUppercaseRequired": "La password deve contenere almeno una lettera maiuscola", + "passwordNumberRequired": "La password deve contenere almeno {minNumbers, plural, =1 {un numero} other {# numeri}}", + "passwordSpecialCharacterRequired": "La password deve contenere almeno un carattere speciale", + "passwordsMustMatch": "Le password non corrispondono", + "confirmPasswordRequired": "Conferma la tua password" + } }, "Login": { "title": "Accedi", @@ -56,6 +66,12 @@ "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", "passwordResetRequired": "È necessario reimpostare la password. Controlla la tua email per le istruzioni su come reimpostare la password.", "invalidToken": "Il tuo link di accesso non è valido o è scaduto. Riprova ad accedere.", + "FieldErrors": { + "emailRequired": "L'indirizzo email è obbligatorio", + "emailInvalid": "Inserisci un indirizzo e-mail valido", + "passwordRequired": "La password è obbligatoria", + "invalidInput": "Controlla i dati inseriti e riprova." + }, "CreateAccount": { "title": "Nuovo cliente?", "accountBenefits": "Crea un account con noi e sarai in grado di:", @@ -70,14 +86,36 @@ "title": "Password dimenticata", "subtitle": "Inserisci di seguito l'e-mail associata al tuo account. Ti invieremo le istruzioni per reimpostare la password.", "confirmResetPassword": "Se l'indirizzo e-mail {email} è collegato a un account nel nostro negozio, ti abbiamo inviato un'e-mail per reimpostare la password. Se non la trovi, controlla le cartelle di posta in arrivo e indesiderata.", - "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." + "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "FieldErrors": { + "emailRequired": "L'indirizzo email è obbligatorio", + "emailInvalid": "Inserisci un indirizzo e-mail valido" + } } }, "Register": { "title": "Registra un account", "heading": "Nuovo account", "cta": "Crea account", - "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." + "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "FieldErrors": { + "firstNameRequired": "Il nome è obbligatorio", + "lastNameRequired": "Il cognome è obbligatorio", + "emailRequired": "L'indirizzo email è obbligatorio", + "emailInvalid": "Inserisci un indirizzo e-mail valido", + "passwordRequired": "La password è obbligatoria", + "passwordTooSmall": "La password deve essere lunga almeno {minLength, plural, =1 {1 carattere} other {# caratteri}}", + "passwordLowercaseRequired": "La password deve contenere almeno una lettera minuscola", + "passwordUppercaseRequired": "La password deve contenere almeno una lettera maiuscola", + "passwordNumberRequired": "La password deve contenere almeno {minNumbers, plural, =1 {un numero} other {# numeri}}", + "passwordSpecialCharacterRequired": "La password deve contenere almeno un carattere speciale", + "passwordsMustMatch": "Le password non corrispondono", + "addressLine1Required": "La riga 1 dell'indirizzo è obbligatoria", + "cityRequired": "La Città è necessaria", + "countryRequired": "Il paese è obbligatorio", + "stateRequired": "Lo Stato/Provincia è obbligatorio", + "postalCodeRequired": "Il CAP è necessario" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", "EmptyState": { "title": "Non hai indirizzi" + }, + "FieldErrors": { + "firstNameRequired": "Il nome è obbligatorio", + "lastNameRequired": "Il cognome è obbligatorio", + "addressLine1Required": "La riga 1 dell'indirizzo è obbligatoria", + "cityRequired": "La Città è necessaria", + "countryRequired": "Il paese è obbligatorio", + "stateRequired": "Lo Stato/Provincia è obbligatorio", + "postalCodeRequired": "Il CAP è necessario" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Iscriviti alla nostra newsletter.", "marketingPreferencesUpdated": "Le preferenze di marketing sono state aggiornate.", "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." + }, + "FieldErrors": { + "firstNameRequired": "Il nome è obbligatorio", + "firstNameTooSmall": "Il nome deve essere lungo almeno 2 caratteri", + "lastNameRequired": "Il cognome è obbligatorio", + "lastNameTooSmall": "Il cognome deve essere lungo almeno 2 caratteri", + "emailRequired": "L'indirizzo email è obbligatorio", + "emailInvalid": "Inserisci un indirizzo e-mail valido", + "currentPasswordRequired": "È necessario inserire la password attuale", + "passwordRequired": "La password è obbligatoria", + "passwordTooSmall": "La password deve essere lunga almeno {minLength, plural, =1 {1 carattere} other {# caratteri}}", + "passwordLowercaseRequired": "La password deve contenere almeno una lettera minuscola", + "passwordUppercaseRequired": "La password deve contenere almeno una lettera maiuscola", + "passwordNumberRequired": "La password deve contenere almeno {minNumbers, plural, =1 {un numero} other {# numeri}}", + "passwordSpecialCharacterRequired": "La password deve contenere almeno un carattere speciale", + "passwordsMustMatch": "Le password non corrispondono", + "confirmPasswordRequired": "Conferma la tua password" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Abbiamo notato che avevi salvato degli articoli in un carrello precedente, quindi li abbiamo aggiunti al carrello attuale.", "cartRestored": "Hai avviato un carrello su un altro dispositivo e lo abbiamo ripristinato qui, così puoi riprendere da dove avevi lasciato.", "cartUpdateInProgress": "Hai un aggiornamento del carrello attivo in corso. Uscire dalla pagina? Le modifiche potrebbero andare perse.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "Il prezzo originale era {price}.", + "currentPrice": "Il prezzo corrente è {price}.", + "quantityReadyToShip": "{quantity, number} pronto per la spedizione", + "quantityOnBackorder": "{quantity, number} sarà in arretrato", + "partiallyAvailable": "Solo {quantity, number} disponibili", "CheckoutSummary": { "title": "Riepilogo", "subTotal": "Subtotale", @@ -335,7 +402,8 @@ "updateShipping": "Aggiorna la spedizione", "addShipping": "Aggiungi spedizione", "cartNotFound": "Si è verificato un errore durante il recupero del carrello", - "noShippingOptions": "Non ci sono opzioni di spedizione disponibili per il tuo indirizzo" + "noShippingOptions": "Non ci sono opzioni di spedizione disponibili per il tuo indirizzo", + "countryRequired": "Il paese è obbligatorio" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Informazioni aggiuntive", "currentStock": "{quantità, numero} in magazzino", "backorderQuantity": "{quantity, number} sarà in arretrato", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Aggiungi al carrello", "outOfStock": "Esaurito", @@ -427,13 +497,24 @@ "button": "Scrivi una recensione", "title": "Scrivi una recensione", "submit": "Invia", + "cancel": "Annulla", "ratingLabel": "Valutazione", "titleLabel": "Titolo", "reviewLabel": "Recensione", "nameLabel": "Nome", "emailLabel": "E-mail", "successMessage": "La tua recensione è stata inviata correttamente.", - "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." + "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "FieldErrors": { + "titleRequired": "Il titolo è obbligatorio", + "authorRequired": "Il nome è obbligatorio", + "emailRequired": "L'indirizzo email è obbligatorio", + "emailInvalid": "Inserisci un indirizzo e-mail valido", + "textRequired": "La revisione è obbligatoria", + "ratingRequired": "La valutazione è obbligatoria", + "ratingTooSmall": "Il punteggio deve essere almeno 1", + "ratingTooLarge": "Il punteggio deve essere al massimo 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Ricevi aggiornamenti sulle ultime novità e offerte dal nostro negozio.", "subscribedToNewsletter": "Hai effettuato l'iscrizione alla nostra newsletter.", "Errors": { - "invalidEmail": "Inserisci un indirizzo e-mail valido.", + "emailRequired": "L'indirizzo email è obbligatorio", + "invalidEmail": "Inserisci un indirizzo e-mail valido", "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "Il prezzo originale era {price}.", + "currentPrice": "Il prezzo corrente è {price}.", + "range": "Prezzo da {minValue} a {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Controlla il saldo", "description": "Puoi controllare il saldo e ottenere informazioni sul tuo buono regalo inserendo il codice nella casella sottostante.", "inputLabel": "Codice", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Acquistato", "senderLabel": "Da", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Riconosco che questo buono regalo scadrà il {expiryDate}", "ctaLabel": "Aggiungi al carrello", "Errors": { - "amountRequired": "Seleziona o inserisci un importo del buono regalo.", - "amountInvalid": "Seleziona un importo valido per il buono regalo.", - "amountOutOfRange": "Inserisci un importo compreso tra {minAmount} e {maxAmount}.", - "unexpectedSettingsError": "Si è verificato un errore imprevisto durante il recupero delle impostazioni del buono regalo. Riprova più tardi." + "amountRequired": "Seleziona o inserisci un importo del buono regalo", + "amountInvalid": "Seleziona un importo valido per il buono regalo", + "amountOutOfRange": "Inserisci un importo compreso tra {minAmount} e {maxAmount}", + "unexpectedSettingsError": "Si è verificato un errore imprevisto durante il recupero delle impostazioni del buono regalo. Riprova più tardi.", + "senderNameRequired": "Il tuo nome è obbligatorio", + "senderEmailRequired": "La tua e-mail è obbligatoria", + "recipientNameRequired": "Il nome del destinatario è obbligatorio", + "recipientEmailRequired": "L'e-mail del destinatario è obbligatoria", + "emailInvalid": "Inserisci un indirizzo e-mail valido", + "checkboxRequired": "Devi spuntare questa casella per continuare" } } } }, "Form": { - "optional": "facoltativo" + "optional": "facoltativo", + "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 08ae0cd1b..ff94c4325 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -43,7 +43,17 @@ "newPassword": "新しいパスワード", "confirmPassword": "パスワード確認", "passwordUpdated": "パスワードが正常に更新されました!", - "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" + "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "FieldErrors": { + "passwordRequired": "パスワードが必要です", + "passwordTooSmall": "パスワードは少なくとも {minLength, plural, =1 {1 character } other {# characters }}の長さである必要があります", + "passwordLowercaseRequired": "パスワードには少なくとも1つの小文字を含める必要があります", + "passwordUppercaseRequired": "パスワードには少なくとも1つの大文字を含める必要があります", + "passwordNumberRequired": "パスワードには少なくとも {minNumbers, plural, =1 {one number } other {#個の数字}}が含まれている必要があります", + "passwordSpecialCharacterRequired": "パスワードには少なくとも1つの特殊文字を含める必要があります", + "passwordsMustMatch": "パスワードが一致しません", + "confirmPasswordRequired": "パスワードを確認してください" + } }, "Login": { "title": "ログイン", @@ -56,6 +66,12 @@ "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", "passwordResetRequired": "パスワードのリセットが必要です。パスワードをリセットするための手順については、メールを確認してください。", "invalidToken": "ログインリンクが無効または期限切れです。もう一度ログインしてください。", + "FieldErrors": { + "emailRequired": "メールアドレスは必須です", + "emailInvalid": "有効なメールアドレスを入力してください", + "passwordRequired": "パスワードが必要です", + "invalidInput": "入力内容を確認して、もう一度お試しください。" + }, "CreateAccount": { "title": "新規のお客様ですか?", "accountBenefits": "アカウントを作成すると、次のことができるようになります:", @@ -70,14 +86,36 @@ "title": "パスワードをお忘れですか", "subtitle": "アカウントに関連付けられたメールアドレスを以下に入力してください。パスワードをリセットするための手順をお送りいたします。", "confirmResetPassword": "メールアドレス {email} が当社のアカウントにリンクされている場合、パスワードリセットのメールが送信されました。受信トレイをご確認ください。見つからない場合は、スパムフォルダもご確認ください。", - "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" + "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "FieldErrors": { + "emailRequired": "メールアドレスは必須です", + "emailInvalid": "有効なメールアドレスを入力してください" + } } }, "Register": { "title": "アカウントを登録", "heading": "新しいアカウント", "cta": "アカウント作成", - "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" + "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "FieldErrors": { + "firstNameRequired": "名は必須です", + "lastNameRequired": "姓は必須です", + "emailRequired": "メールアドレスは必須です", + "emailInvalid": "有効なメールアドレスを入力してください", + "passwordRequired": "パスワードが必要です", + "passwordTooSmall": "パスワードは少なくとも {minLength, plural, =1 {1 character } other {# characters }}の長さである必要があります", + "passwordLowercaseRequired": "パスワードには少なくとも1つの小文字を含める必要があります", + "passwordUppercaseRequired": "パスワードには少なくとも1つの大文字を含める必要があります", + "passwordNumberRequired": "パスワードには少なくとも {minNumbers, plural, =1 {one number } other {#個の数字}}が含まれている必要があります", + "passwordSpecialCharacterRequired": "パスワードには少なくとも1つの特殊文字を含める必要があります", + "passwordsMustMatch": "パスワードが一致しません", + "addressLine1Required": "住所1行目は必須です", + "cityRequired": "市区町村が必要です", + "countryRequired": "国名コードが必要です", + "stateRequired": "都道府県が必要です", + "postalCodeRequired": "郵便番号は必要です" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", "EmptyState": { "title": "アドレスがありません" + }, + "FieldErrors": { + "firstNameRequired": "名は必須です", + "lastNameRequired": "姓は必須です", + "addressLine1Required": "住所1行目は必須です", + "cityRequired": "市区町村が必要です", + "countryRequired": "国名コードが必要です", + "stateRequired": "都道府県が必要です", + "postalCodeRequired": "郵便番号は必要です" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "ニュースレターに登録してください。", "marketingPreferencesUpdated": "マーケティング設定が正常に更新されました。", "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" + }, + "FieldErrors": { + "firstNameRequired": "名は必須です", + "firstNameTooSmall": "名は2文字以上でなければなりません", + "lastNameRequired": "姓は必須です", + "lastNameTooSmall": "姓は2文字以上でなければなりません", + "emailRequired": "メールアドレスは必須です", + "emailInvalid": "有効なメールアドレスを入力してください", + "currentPasswordRequired": "現在のパスワードは必須です", + "passwordRequired": "パスワードが必要です", + "passwordTooSmall": "パスワードは少なくとも {minLength, plural, =1 {1 character } other {# characters }}の長さである必要があります", + "passwordLowercaseRequired": "パスワードには少なくとも1つの小文字を含める必要があります", + "passwordUppercaseRequired": "パスワードには少なくとも1つの大文字を含める必要があります", + "passwordNumberRequired": "パスワードには少なくとも {minNumbers, plural, =1 {one number } other {#個の数字}}が含まれている必要があります", + "passwordSpecialCharacterRequired": "パスワードには少なくとも1つの特殊文字を含める必要があります", + "passwordsMustMatch": "パスワードが一致しません", + "confirmPasswordRequired": "パスワードを確認してください" } } }, @@ -304,8 +368,11 @@ "cartCombined": "以前のカートに商品が保存されていたので、それを現在のカートに追加しました。", "cartRestored": "別のデバイスでカートに商品が追加されていたため、中断されたところからお買い物を再開できるよう、こちらにカートの内容を復元しました。", "cartUpdateInProgress": "カートの更新が進行中です。このページを離れてもよろしいですか?変更内容が失われる可能性があります。", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "元の価格は{price}でした。", + "currentPrice": "現在の価格は{price}です。", + "quantityReadyToShip": "{quantity, number} 個、発送準備完了", + "quantityOnBackorder": "{quantity, number} はバックオーダーになります", + "partiallyAvailable": "{quantity, number} 個のみ在庫あり", "CheckoutSummary": { "title": "要約", "subTotal": "小計", @@ -335,7 +402,8 @@ "updateShipping": "配送情報を更新", "addShipping": "配送を追加", "cartNotFound": "カートの取得中にエラーが発生しました", - "noShippingOptions": "ご指定の住所で利用できる配送オプションはありません" + "noShippingOptions": "ご指定の住所で利用できる配送オプションはありません", + "countryRequired": "国名コードが必要です" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "追加情報", "currentStock": "{quantity, number} 個の在庫あり", "backorderQuantity": "{quantity, number} はバックオーダーになります", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "カートに追加", "outOfStock": "品切れ", @@ -427,13 +497,24 @@ "button": "レビューを書く", "title": "レビューを書く", "submit": "提出", + "cancel": "キャンセル", "ratingLabel": "評価", "titleLabel": "タイトル", "reviewLabel": "レビュー", "nameLabel": "名前", "emailLabel": "Eメール", "successMessage": "レビューは正常に送信されました。", - "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" + "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "FieldErrors": { + "titleRequired": "タイトルは必須です", + "authorRequired": "商品名は必須です", + "emailRequired": "メールアドレスは必須です", + "emailInvalid": "有効なメールアドレスを入力してください", + "textRequired": "レビューが必要です", + "ratingRequired": "評価は必須です", + "ratingTooSmall": "評価は1以上である必要があります", + "ratingTooLarge": "評価は5以下でなければなりません" + } } } }, @@ -515,7 +596,8 @@ "description": "当店の最新ニュースやオファーをぜひチェックしてください。", "subscribedToNewsletter": "ニュースレターを購読されました。", "Errors": { - "invalidEmail": "有効なメールアドレスを入力してください。", + "emailRequired": "メールアドレスは必須です", + "invalidEmail": "有効なメールアドレスを入力してください", "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "元の価格は{price}でした。", + "currentPrice": "現在の価格は{price}です。", + "range": "価格は{minValue}から{maxValue}までです。" } }, "GiftCertificates": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "このギフト券は{expiryDate}に有効期限が切れることに同意します。", "ctaLabel": "カートに追加", "Errors": { - "amountRequired": "ギフト券の金額を選択または入力してください。", - "amountInvalid": "有効なギフト券の金額を選択してください。", - "amountOutOfRange": "{minAmount}から{maxAmount}の間の金額を入力してください。", - "unexpectedSettingsError": "ギフト券設定の取得中に予期しないエラーが発生しました。しばらくしてから再度お試しください" + "amountRequired": "ギフト券の金額を選択または入力してください", + "amountInvalid": "有効なギフト券の金額を選択してください", + "amountOutOfRange": "{minAmount}から{maxAmount}の間の金額を入力してください", + "unexpectedSettingsError": "ギフト券設定の取得中に予期しないエラーが発生しました。しばらくしてから再度お試しください", + "senderNameRequired": "お名前は必須です", + "senderEmailRequired": "メールアドレスは必須です", + "recipientNameRequired": "受取人の名前は必須です", + "recipientEmailRequired": "受信者のメールアドレスは必須です", + "emailInvalid": "有効なメールアドレスを入力してください", + "checkboxRequired": "続行するにはこのボックスにチェックを入れてください" } } } }, "Form": { - "optional": "オプション" + "optional": "オプション", + "Errors": { + "invalidInput": "入力内容を確認してもう一度お試しください", + "invalidFormat": "入力された値は必要な形式と一致しません" + } } } diff --git a/core/messages/nl.json b/core/messages/nl.json index 99a55d121..fc30faaa6 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -43,7 +43,17 @@ "newPassword": "Nieuw wachtwoord", "confirmPassword": "Wachtwoord bevestigen", "passwordUpdated": "Wachtwoord is succesvol bijgewerkt!", - "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." + "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "FieldErrors": { + "passwordRequired": "Wachtwoord is vereist", + "passwordTooSmall": "Het wachtwoord moet minimaal {minLength, plural, =1 {1 teken} other {# tekens}} lang zijn", + "passwordLowercaseRequired": "Het wachtwoord moet minstens één kleine letter bevatten", + "passwordUppercaseRequired": "Het wachtwoord moet minimaal één hoofdletter bevatten", + "passwordNumberRequired": "Het wachtwoord moet minimaal {minNumbers, plural, =1 {één cijfer} other {# cijfers}} bevatten", + "passwordSpecialCharacterRequired": "Het wachtwoord moet minimaal één speciaal teken bevatten", + "passwordsMustMatch": "De wachtwoorden komen niet overeen", + "confirmPasswordRequired": "Bevestig je wachtwoord" + } }, "Login": { "title": "Inloggen", @@ -56,6 +66,12 @@ "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", "passwordResetRequired": "Wachtwoord opnieuw instellen is vereist. Controleer uw e-mail voor instructies om uw wachtwoord opnieuw in te stellen.", "invalidToken": "Je aanmeldingslink is ongeldig of verlopen. Probeer opnieuw in te loggen.", + "FieldErrors": { + "emailRequired": "E-mailadres is vereist", + "emailInvalid": "Voer een geldig e-mailadres in", + "passwordRequired": "Wachtwoord is vereist", + "invalidInput": "Controleer uw invoer en probeer het opnieuw." + }, "CreateAccount": { "title": "Nieuwe klant?", "accountBenefits": "Maak een account aan bij ons om:", @@ -70,14 +86,36 @@ "title": "Wachtwoord vergeten", "subtitle": "Voer hieronder het e-mailadres in dat aan je account is gekoppeld. We sturen je instructies om je wachtwoord opnieuw in te stellen.", "confirmResetPassword": "Als het e-mailadres {email} is gekoppeld aan een account in onze winkel, hebben we je een e-mail gestuurd om je wachtwoord opnieuw in te stellen. Controleer je inbox en spammap als je de e-mail niet ziet.", - "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." + "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "FieldErrors": { + "emailRequired": "E-mailadres is vereist", + "emailInvalid": "Voer een geldig e-mailadres in" + } } }, "Register": { "title": "Account registreren", "heading": "Nieuw account", "cta": "Account aanmaken", - "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." + "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "FieldErrors": { + "firstNameRequired": "Voornaam is vereist", + "lastNameRequired": "Achternaam is vereist", + "emailRequired": "E-mailadres is vereist", + "emailInvalid": "Voer een geldig e-mailadres in", + "passwordRequired": "Wachtwoord is vereist", + "passwordTooSmall": "Het wachtwoord moet minimaal {minLength, plural, =1 {1 teken} other {# tekens}} lang zijn", + "passwordLowercaseRequired": "Het wachtwoord moet minstens één kleine letter bevatten", + "passwordUppercaseRequired": "Het wachtwoord moet minimaal één hoofdletter bevatten", + "passwordNumberRequired": "Het wachtwoord moet minimaal {minNumbers, plural, =1 {één cijfer} other {# cijfers}} bevatten", + "passwordSpecialCharacterRequired": "Het wachtwoord moet minimaal één speciaal teken bevatten", + "passwordsMustMatch": "De wachtwoorden komen niet overeen", + "addressLine1Required": "Adresregel 1 is vereist", + "cityRequired": "Plaats is vereist", + "countryRequired": "Land is vereist", + "stateRequired": "Staat/provincie is vereist", + "postalCodeRequired": "Postcode is vereist" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", "EmptyState": { "title": "Je hebt geen adressen" + }, + "FieldErrors": { + "firstNameRequired": "Voornaam is vereist", + "lastNameRequired": "Achternaam is vereist", + "addressLine1Required": "Adresregel 1 is vereist", + "cityRequired": "Plaats is vereist", + "countryRequired": "Land is vereist", + "stateRequired": "Staat/provincie is vereist", + "postalCodeRequired": "Postcode is vereist" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Meld u aan voor onze nieuwsbrief.", "marketingPreferencesUpdated": "Marketingvoorkeuren zijn bijgewerkt!", "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." + }, + "FieldErrors": { + "firstNameRequired": "Voornaam is vereist", + "firstNameTooSmall": "De voornaam moet minimaal 2 tekens lang zijn", + "lastNameRequired": "Achternaam is vereist", + "lastNameTooSmall": "De achternaam moet minimaal 2 tekens lang zijn", + "emailRequired": "E-mailadres is vereist", + "emailInvalid": "Voer een geldig e-mailadres in", + "currentPasswordRequired": "Huidig wachtwoord is vereist", + "passwordRequired": "Wachtwoord is vereist", + "passwordTooSmall": "Het wachtwoord moet minimaal {minLength, plural, =1 {1 teken} other {# tekens}} lang zijn", + "passwordLowercaseRequired": "Het wachtwoord moet minstens één kleine letter bevatten", + "passwordUppercaseRequired": "Het wachtwoord moet minimaal één hoofdletter bevatten", + "passwordNumberRequired": "Het wachtwoord moet minimaal {minNumbers, plural, =1 {één cijfer} other {# cijfers}} bevatten", + "passwordSpecialCharacterRequired": "Het wachtwoord moet minimaal één speciaal teken bevatten", + "passwordsMustMatch": "De wachtwoorden komen niet overeen", + "confirmPasswordRequired": "Bevestig je wachtwoord" } } }, @@ -304,8 +368,11 @@ "cartCombined": "We hebben gezien dat je artikelen in een eerder winkelmandje had opgeslagen, dus we hebben ze voor je aan je huidige winkelmandje toegevoegd.", "cartRestored": "Je bent een winkelmandje begonnen op een ander apparaat en we hebben het hier hersteld, zodat je verder kunt gaan waar je was gebleven.", "cartUpdateInProgress": "Je winkelwagentje wordt bijgewerkt. Weet je zeker dat je deze pagina wilt verlaten? Je wijzigingen kunnen verloren gaan.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "De oorspronkelijke prijs was {price}.", + "currentPrice": "De huidige prijs is {price}.", + "quantityReadyToShip": "{quantity, number} klaar voor verzending", + "quantityOnBackorder": "{quantity, number} staat in backorder", + "partiallyAvailable": "Slechts {quantity, number} beschikbaar", "CheckoutSummary": { "title": "Overzicht", "subTotal": "Subtotaal", @@ -335,7 +402,8 @@ "updateShipping": "Verzending bijwerken", "addShipping": "Verzending toevoegen", "cartNotFound": "Er is een fout opgetreden bij het ophalen van je winkelwagen", - "noShippingOptions": "Er zijn geen verzendopties beschikbaar voor je adres" + "noShippingOptions": "Er zijn geen verzendopties beschikbaar voor je adres", + "countryRequired": "Land is vereist" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Aanvullende informatie", "currentStock": "{quantity, number} op voorraad", "backorderQuantity": "{hoeveelheid, aantal} staat in backorder", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Toevoegen aan winkelmandje", "outOfStock": "Niet op voorraad", @@ -427,13 +497,24 @@ "button": "Schrijf een beoordeling", "title": "Schrijf een beoordeling", "submit": "Verzenden", + "cancel": "Annuleren", "ratingLabel": "Beoordeling", "titleLabel": "Titel", "reviewLabel": "Beoordelen", "nameLabel": "Naam", "emailLabel": "E-mailadres", "successMessage": "Je beoordeling is succesvol ingediend!", - "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." + "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "FieldErrors": { + "titleRequired": "Titel is vereist", + "authorRequired": "Naam is verplicht", + "emailRequired": "E-mailadres is vereist", + "emailInvalid": "Voer een geldig e-mailadres in", + "textRequired": "Beoordeling is vereist", + "ratingRequired": "Beoordeling is vereist", + "ratingTooSmall": "De beoordeling moet minimaal 1 zijn", + "ratingTooLarge": "De beoordeling moet maximaal 5 zijn" + } } } }, @@ -515,7 +596,8 @@ "description": "Blijf op de hoogte van het laatste nieuws en aanbiedingen van onze winkel.", "subscribedToNewsletter": "Je bent nu geabonneerd op onze nieuwsbrief!", "Errors": { - "invalidEmail": "Voer een geldig e-mailadres in.", + "emailRequired": "E-mailadres is vereist", + "invalidEmail": "Voer een geldig e-mailadres in", "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "De oorspronkelijke prijs was {price}.", + "currentPrice": "De huidige prijs is {price}.", + "range": "Prijs van {minValue} tot {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Saldo controleren", "description": "Je kunt het saldo controleren en de informatie over je cadeaubon opvragen door de code in het vak hieronder in te voeren.", "inputLabel": "Code", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Gekocht", "senderLabel": "Van", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Ik erken dat deze cadeaubon vervalt op {expiryDate}", "ctaLabel": "Toevoegen aan winkelmandje", "Errors": { - "amountRequired": "Selecteer of voer een cadeaubonbedrag in.", - "amountInvalid": "Selecteer een geldig cadeaubonbedrag.", - "amountOutOfRange": "Voer een bedrag in tussen {minAmount} en {maxAmount}.", - "unexpectedSettingsError": "Er is een onverwachte fout opgetreden bij het ophalen van de instellingen voor de cadeaubon. Probeer het later opnieuw." + "amountRequired": "Selecteer of voer een cadeaubonbedrag in", + "amountInvalid": "Selecteer een geldig cadeaubonbedrag", + "amountOutOfRange": "Voer een bedrag in tussen {minAmount} en {maxAmount}", + "unexpectedSettingsError": "Er is een onverwachte fout opgetreden bij het ophalen van de instellingen voor de cadeaubon. Probeer het later opnieuw.", + "senderNameRequired": "Uw naam is verplicht", + "senderEmailRequired": "Uw e-mailadres is vereist", + "recipientNameRequired": "De naam van de ontvanger is vereist", + "recipientEmailRequired": "Het e-mailadres van de ontvanger is vereist", + "emailInvalid": "Voer een geldig e-mailadres in", + "checkboxRequired": "U moet dit vakje aanvinken om door te gaan" } } } }, "Form": { - "optional": "optioneel" + "optional": "optioneel", + "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 7cc6eecc7..99b29e946 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -43,7 +43,17 @@ "newPassword": "Nytt passord", "confirmPassword": "Bekreft passord", "passwordUpdated": "Passordet er oppdatert!", - "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." + "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "FieldErrors": { + "passwordRequired": "Passord er påkrevd", + "passwordTooSmall": "Passordet må være minst {minLength, plural, =1 {1 character} other {# tegn}} langt", + "passwordLowercaseRequired": "Passordet må inneholde minst én liten bokstav", + "passwordUppercaseRequired": "Passordet må inneholde minst én stor bokstav", + "passwordNumberRequired": "Passordet må inneholde minst {minNumbers, plural, =1 {ett tall} other {# tall}}", + "passwordSpecialCharacterRequired": "Passordet må inneholde minst ett spesialtegn", + "passwordsMustMatch": "Passordene stemmer ikke overens", + "confirmPasswordRequired": "Bekreft passordet ditt" + } }, "Login": { "title": "Logg inn", @@ -56,6 +66,12 @@ "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", "passwordResetRequired": "Krever tilbakestilling av passord. Kontroller e-posten din for instruksjoner om hvordan du tilbakestiller passordet.", "invalidToken": "Innloggingskoblingen din er ugyldig eller har utløpt. Prøv å logge inn igjen.", + "FieldErrors": { + "emailRequired": "E-post er påkrevd", + "emailInvalid": "Skriv inn en gyldig e-postadresse", + "passwordRequired": "Passord er påkrevd", + "invalidInput": "Sjekk inndataene dine og prøv igjen." + }, "CreateAccount": { "title": "Ny kunde?", "accountBenefits": "Opprett en konto hos oss, så kan du:", @@ -70,14 +86,36 @@ "title": "Glemt passord", "subtitle": "Skriv inn e-posten som er knyttet til kontoen din nedenfor. Vi sender deg instruksjoner for å tilbakestille passordet ditt.", "confirmResetPassword": "Hvis e-postadressen {email} er koblet til en konto i butikken vår, har vi sendt deg en e-post for tilbakestilling av passord. Sjekk innboksen og søppelpostmappen hvis du ikke ser den.", - "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." + "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "FieldErrors": { + "emailRequired": "E-post er påkrevd", + "emailInvalid": "Skriv inn en gyldig e-postadresse" + } } }, "Register": { "title": "Registrer deg for en konto", "heading": "Ny konto", "cta": "Opprett konto", - "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." + "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "FieldErrors": { + "firstNameRequired": "Fornavn er påkrevd", + "lastNameRequired": "Etternavn er påkrevd", + "emailRequired": "E-post er påkrevd", + "emailInvalid": "Skriv inn en gyldig e-postadresse", + "passwordRequired": "Passord er påkrevd", + "passwordTooSmall": "Passordet må være minst {minLength, plural, =1 {1 character} other {# tegn}} langt", + "passwordLowercaseRequired": "Passordet må inneholde minst én liten bokstav", + "passwordUppercaseRequired": "Passordet må inneholde minst én stor bokstav", + "passwordNumberRequired": "Passordet må inneholde minst {minNumbers, plural, =1 {ett tall} other {# tall}}", + "passwordSpecialCharacterRequired": "Passordet må inneholde minst ett spesialtegn", + "passwordsMustMatch": "Passordene stemmer ikke overens", + "addressLine1Required": "Adresselinje 1 er påkrevd", + "cityRequired": "By er påkrevd", + "countryRequired": "Land er påkrevd", + "stateRequired": "Delstat/provins er påkrevd", + "postalCodeRequired": "Postnummer er påkrevd" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", "EmptyState": { "title": "Du har ingen adresser" + }, + "FieldErrors": { + "firstNameRequired": "Fornavn er påkrevd", + "lastNameRequired": "Etternavn er påkrevd", + "addressLine1Required": "Adresselinje 1 er påkrevd", + "cityRequired": "By er påkrevd", + "countryRequired": "Land er påkrevd", + "stateRequired": "Delstat/provins er påkrevd", + "postalCodeRequired": "Postnummer er påkrevd" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Abonner på nyhetsbrevet vårt.", "marketingPreferencesUpdated": "Markedsføringsinnstillingene er oppdatert!", "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." + }, + "FieldErrors": { + "firstNameRequired": "Fornavn er påkrevd", + "firstNameTooSmall": "Fornavnet må være minst 2 tegn langt", + "lastNameRequired": "Etternavn er påkrevd", + "lastNameTooSmall": "Etternavnet må være minst 2 tegn langt", + "emailRequired": "E-post er påkrevd", + "emailInvalid": "Skriv inn en gyldig e-postadresse", + "currentPasswordRequired": "Gjeldende passord kreves", + "passwordRequired": "Passord er påkrevd", + "passwordTooSmall": "Passordet må være minst {minLength, plural, =1 {1 character} other {# tegn}} langt", + "passwordLowercaseRequired": "Passordet må inneholde minst én liten bokstav", + "passwordUppercaseRequired": "Passordet må inneholde minst én stor bokstav", + "passwordNumberRequired": "Passordet må inneholde minst {minNumbers, plural, =1 {ett tall} other {# tall}}", + "passwordSpecialCharacterRequired": "Passordet må inneholde minst ett spesialtegn", + "passwordsMustMatch": "Passordene stemmer ikke overens", + "confirmPasswordRequired": "Bekreft passordet ditt" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Vi la merke til at du hadde varer lagret i en tidligere handlekurv, så vi har lagt dem til i din nåværende handlekurv for deg.", "cartRestored": "Du startet en handlekurv på en annen enhet, og vi har gjenopprettet den her, slik at du kan fortsette der du slapp.", "cartUpdateInProgress": "Du har en handlekurvoppdatering i gang. Er du sikker på at du vil forlate denne siden? Endringene kan gå tapt.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "Opprinnelig pris var {price}.", + "currentPrice": "Nåværende pris er {price}.", + "quantityReadyToShip": "{antall, number} klar til sending", + "quantityOnBackorder": "{antall, nummer} vil være restordre", + "partiallyAvailable": "Kun {antall, number} tilgjengelig", "CheckoutSummary": { "title": "Sammendrag", "subTotal": "Delsum", @@ -335,7 +402,8 @@ "updateShipping": "Oppdater forsendelse", "addShipping": "Legg til forsendelse", "cartNotFound": "Det oppsto en feil da du hentet handlekurven din", - "noShippingOptions": "Det finnes ingen tilgjengelige forsendelsesalternativer for adressen din" + "noShippingOptions": "Det finnes ingen tilgjengelige forsendelsesalternativer for adressen din", + "countryRequired": "Land er påkrevd" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Mer informasjon", "currentStock": "{antall, nummer} på lager", "backorderQuantity": "{antall, nummer} vil være restordre", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Legg i handlekurv", "outOfStock": "Utsolgt", @@ -427,13 +497,24 @@ "button": "Skriv en anmeldelse", "title": "Skriv en anmeldelse", "submit": "Send inn", + "cancel": "Avbryt", "ratingLabel": "Vurdering", "titleLabel": "Tittel", "reviewLabel": "Anmeldelse", "nameLabel": "Navn", "emailLabel": "E-post", "successMessage": "Din anmeldelse er sendt inn!", - "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." + "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "FieldErrors": { + "titleRequired": "Tittel er påkrevd", + "authorRequired": "Navn er påkrevd", + "emailRequired": "E-post er påkrevd", + "emailInvalid": "Skriv inn en gyldig e-postadresse", + "textRequired": "Gjennomgang er nødvendig", + "ratingRequired": "Vurdering er påkrevd", + "ratingTooSmall": "Vurderingen må være minst 1", + "ratingTooLarge": "Vurderingen må være maksimalt 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Hold deg oppdatert med de siste nyhetene og tilbudene fra butikken vår.", "subscribedToNewsletter": "Du har abonnert på nyhetsbrevet vårt.", "Errors": { - "invalidEmail": "Skriv inn en gyldig e-postadresse.", + "emailRequired": "E-post er påkrevd", + "invalidEmail": "Skriv inn en gyldig e-postadresse", "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "Opprinnelig pris var {price}.", + "currentPrice": "Nåværende pris er {price}.", + "range": "Pris fra {minValue} til {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Sjekk saldo", "description": "Du kan sjekke saldoen og få informasjon om gavekortet ved å skrive inn koden i boksen nedenfor.", "inputLabel": "Kode", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Kjøpt", "senderLabel": "Fra", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Jeg erkjenner at dette gavekortet utløper {expiryDate}", "ctaLabel": "Legg i handlekurv", "Errors": { - "amountRequired": "Velg eller skriv inn et beløp for gavekortet.", - "amountInvalid": "Velg et gyldig gavekortbeløp.", - "amountOutOfRange": "Skriv inn et beløp mellom {minAmount} og {maxAmount}.", - "unexpectedSettingsError": "Det oppstod en uventet feil under henting av innstillinger for gavekort. Prøv på nytt senere." + "amountRequired": "Velg eller skriv inn et gavekortbeløp", + "amountInvalid": "Velg et gyldig gavekortbeløp", + "amountOutOfRange": "Skriv inn et beløp mellom {minAmount} og {maxAmount}", + "unexpectedSettingsError": "Det oppstod en uventet feil under henting av innstillinger for gavekort. Prøv på nytt senere.", + "senderNameRequired": "Ditt navn er påkrevd", + "senderEmailRequired": "Din e-postadresse er påkrevd", + "recipientNameRequired": "Mottakerens navn er obligatorisk", + "recipientEmailRequired": "Mottakerens e-postadresse er obligatorisk", + "emailInvalid": "Skriv inn en gyldig e-postadresse", + "checkboxRequired": "Du må krysse av i denne boksen for å fortsette" } } } }, "Form": { - "optional": "valgfri" + "optional": "valgfri", + "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 61dbadc8a..07320b139 100644 --- a/core/messages/pl.json +++ b/core/messages/pl.json @@ -43,7 +43,17 @@ "newPassword": "Nowe hasło", "confirmPassword": "Potwierdź hasło", "passwordUpdated": "Hasło zostało pomyślnie zaktualizowane!", - "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." + "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "FieldErrors": { + "passwordRequired": "Hasło jest wymagane", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" + } }, "Login": { "title": "Zaloguj się", @@ -56,6 +66,12 @@ "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", "passwordResetRequired": "Password reset required. Please check your email for instructions to reset your password.", "invalidToken": "Your login link is invalid or has expired. Please try logging in again.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Hasło jest wymagane", + "invalidInput": "Please check your input and try again." + }, "CreateAccount": { "title": "Nowy klient?", "accountBenefits": "Załóż u nas konto, a będziesz mógł:", @@ -70,14 +86,36 @@ "title": "Nie pamiętam hasła", "subtitle": "Podaj poniżej adres e-mail powiązany z Twoim kontem. Wyślemy Ci instrukcje resetowania hasła.", "confirmResetPassword": "Jeśli adres e-mail {email} jest powiązany z kontem w naszym sklepie, otrzymasz wiadomość e-mail umożliwiającą zresetowanie hasła. Jeśli jej nie widzisz, sprawdź skrzynkę odbiorczą i folder ze spamem.", - "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." + "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address" + } } }, "Register": { "title": "Zarejestruj konto", "heading": "Nowe konto", "cta": "Utwórz konto", - "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." + "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "FieldErrors": { + "firstNameRequired": "Imię jest wymagane", + "lastNameRequired": "Nazwisko jest wymagane", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Hasło jest wymagane", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "Miasto jest wymagane", + "countryRequired": "Wymagany jest kraj", + "stateRequired": "Wymagany jest stan/prowincja", + "postalCodeRequired": "Kod pocztowy jest wymagany" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", "EmptyState": { "title": "You don't have any addresses" + }, + "FieldErrors": { + "firstNameRequired": "Imię jest wymagane", + "lastNameRequired": "Nazwisko jest wymagane", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "Miasto jest wymagane", + "countryRequired": "Wymagany jest kraj", + "stateRequired": "Wymagany jest stan/prowincja", + "postalCodeRequired": "Kod pocztowy jest wymagany" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Zapisz się do naszego newslettera.", "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!", "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." + }, + "FieldErrors": { + "firstNameRequired": "Imię jest wymagane", + "firstNameTooSmall": "First name must be at least 2 characters long", + "lastNameRequired": "Nazwisko jest wymagane", + "lastNameTooSmall": "Last name must be at least 2 characters long", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "currentPasswordRequired": "Wymagane jest aktualne hasło", + "passwordRequired": "Hasło jest wymagane", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" } } }, @@ -306,6 +370,9 @@ "cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.", "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", + "quantityReadyToShip": "{quantity, number} ready to ship", + "quantityOnBackorder": "{quantity, number} will be backordered", + "partiallyAvailable": "Only {quantity, number} available", "CheckoutSummary": { "title": "Podsumowanie", "subTotal": "Suma cząstkowa", @@ -335,7 +402,8 @@ "updateShipping": "Zaktualizuj wysyłkę", "addShipping": "Dodaj wysyłkę", "cartNotFound": "An error occurred when retrieving your cart", - "noShippingOptions": "Brak dostępnych opcji wysyłki dla Państwa adresu" + "noShippingOptions": "Brak dostępnych opcji wysyłki dla Państwa adresu", + "countryRequired": "Wymagany jest kraj" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Informacje dodatkowe", "currentStock": "{quantity, number} in stock", "backorderQuantity": "{quantity, number} will be on backorder", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Dodaj do koszyka", "outOfStock": "Brak w magazynie", @@ -427,13 +497,24 @@ "button": "Write a review", "title": "Write a review", "submit": "Wyślij", + "cancel": "Anuluj", "ratingLabel": "Ocena", "titleLabel": "Tytuł", "reviewLabel": "Przegląd", "nameLabel": "Nazwa", "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", - "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." + "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "FieldErrors": { + "titleRequired": "Title is required", + "authorRequired": "Nazwa jest wymagana", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "textRequired": "Review is required", + "ratingRequired": "Rating is required", + "ratingTooSmall": "Rating must be at least 1", + "ratingTooLarge": "Rating must be at most 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Bądź na bieżąco z najnowszymi informacjami i ofertami naszego sklepu.", "subscribedToNewsletter": "You have been subscribed to our newsletter!", "Errors": { - "invalidEmail": "Wpisz prawidłowy adres e-mail.", + "emailRequired": "Email is required", + "invalidEmail": "Please enter a valid email address", "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." } }, @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "I acknowledge that this Gift Certificate will expire on {expiryDate}", "ctaLabel": "Dodaj do koszyka", "Errors": { - "amountRequired": "Please select or enter a gift certificate amount.", - "amountInvalid": "Please select a valid gift certificate amount.", - "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}.", - "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later." + "amountRequired": "Please select or enter a gift certificate amount", + "amountInvalid": "Please select a valid gift certificate amount", + "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}", + "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later.", + "senderNameRequired": "Your name is required", + "senderEmailRequired": "Your email is required", + "recipientNameRequired": "Recipient's name is required", + "recipientEmailRequired": "Recipient's email is required", + "emailInvalid": "Please enter a valid email address", + "checkboxRequired": "You must check this box to continue" } } } }, "Form": { - "optional": "opcjonalne" + "optional": "opcjonalne", + "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 dd7f7f5b9..5675ebd3e 100644 --- a/core/messages/pt-BR.json +++ b/core/messages/pt-BR.json @@ -43,7 +43,17 @@ "newPassword": "Nova senha", "confirmPassword": "Confirmar senha", "passwordUpdated": "A senha foi atualizada com sucesso!", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "passwordRequired": "A senha é obrigatória", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" + } }, "Login": { "title": "Acesso", @@ -56,6 +66,12 @@ "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", "passwordResetRequired": "Password reset required. Please check your email for instructions to reset your password.", "invalidToken": "Your login link is invalid or has expired. Please try logging in again.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "A senha é obrigatória", + "invalidInput": "Please check your input and try again." + }, "CreateAccount": { "title": "Cliente novo?", "accountBenefits": "Crie uma conta conosco para poder:", @@ -70,14 +86,36 @@ "title": "Esqueci a senha", "subtitle": "Insira abaixo o e-mail associado à sua conta. Enviaremos instruções para redefinir sua senha.", "confirmResetPassword": "Se o endereço de e-mail {email} está vinculado a uma conta na nossa loja, enviamos um e-mail de redefinição de senha. Verifique sua caixa de entrada e, caso não encontre, confira a pasta de spam.", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address" + } } }, "Register": { "title": "Registrar conta", "heading": "Nova conta", "cta": "Criar conta", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "firstNameRequired": "O primeiro nome é obrigatório", + "lastNameRequired": "O sobrenome é obrigatório", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "A senha é obrigatória", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "É obrigatório informar a cidade", + "countryRequired": "O país é obrigatório", + "stateRequired": "Estado/Província é obrigatório", + "postalCodeRequired": "O código postal é obrigatório" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", "EmptyState": { "title": "You don't have any addresses" + }, + "FieldErrors": { + "firstNameRequired": "O primeiro nome é obrigatório", + "lastNameRequired": "O sobrenome é obrigatório", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "É obrigatório informar a cidade", + "countryRequired": "O país é obrigatório", + "stateRequired": "Estado/Província é obrigatório", + "postalCodeRequired": "O código postal é obrigatório" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Inscreva-se no nosso boletim informativo.", "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + }, + "FieldErrors": { + "firstNameRequired": "O primeiro nome é obrigatório", + "firstNameTooSmall": "First name must be at least 2 characters long", + "lastNameRequired": "O sobrenome é obrigatório", + "lastNameTooSmall": "Last name must be at least 2 characters long", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "currentPasswordRequired": "A senha atual é obrigatória", + "passwordRequired": "A senha é obrigatória", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" } } }, @@ -306,6 +370,9 @@ "cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.", "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", + "quantityReadyToShip": "{quantity, number} ready to ship", + "quantityOnBackorder": "{quantity, number} will be backordered", + "partiallyAvailable": "Only {quantity, number} available", "CheckoutSummary": { "title": "Resumo", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Atualizar envio", "addShipping": "Adicionar envio", "cartNotFound": "An error occurred when retrieving your cart", - "noShippingOptions": "Não há opções de envio disponíveis para o seu endereço" + "noShippingOptions": "Não há opções de envio disponíveis para o seu endereço", + "countryRequired": "O país é obrigatório" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Outras informações", "currentStock": "{quantity, number} in stock", "backorderQuantity": "{quantity, number} will be on backorder", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Adicionar ao carrinho", "outOfStock": "Fora do estoque", @@ -427,13 +497,24 @@ "button": "Write a review", "title": "Write a review", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Taxa", "titleLabel": "Título", "reviewLabel": "Avaliação", "nameLabel": "Nome", "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "titleRequired": "Title is required", + "authorRequired": "O nome é obrigatório", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "textRequired": "Review is required", + "ratingRequired": "Rating is required", + "ratingTooSmall": "Rating must be at least 1", + "ratingTooLarge": "Rating must be at most 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Fique por dentro das últimas novidades e ofertas da nossa loja.", "subscribedToNewsletter": "You have been subscribed to our newsletter!", "Errors": { - "invalidEmail": "Informe um endereço de email válido.", + "emailRequired": "Email is required", + "invalidEmail": "Please enter a valid email address", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." } }, @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "I acknowledge that this Gift Certificate will expire on {expiryDate}", "ctaLabel": "Adicionar ao carrinho", "Errors": { - "amountRequired": "Please select or enter a gift certificate amount.", - "amountInvalid": "Please select a valid gift certificate amount.", - "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}.", - "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later." + "amountRequired": "Please select or enter a gift certificate amount", + "amountInvalid": "Please select a valid gift certificate amount", + "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}", + "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later.", + "senderNameRequired": "Your name is required", + "senderEmailRequired": "Your email is required", + "recipientNameRequired": "Recipient's name is required", + "recipientEmailRequired": "Recipient's email is required", + "emailInvalid": "Please enter a valid email address", + "checkboxRequired": "You must check this box to continue" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 dd7f7f5b9..5675ebd3e 100644 --- a/core/messages/pt.json +++ b/core/messages/pt.json @@ -43,7 +43,17 @@ "newPassword": "Nova senha", "confirmPassword": "Confirmar senha", "passwordUpdated": "A senha foi atualizada com sucesso!", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "passwordRequired": "A senha é obrigatória", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" + } }, "Login": { "title": "Acesso", @@ -56,6 +66,12 @@ "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", "passwordResetRequired": "Password reset required. Please check your email for instructions to reset your password.", "invalidToken": "Your login link is invalid or has expired. Please try logging in again.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "A senha é obrigatória", + "invalidInput": "Please check your input and try again." + }, "CreateAccount": { "title": "Cliente novo?", "accountBenefits": "Crie uma conta conosco para poder:", @@ -70,14 +86,36 @@ "title": "Esqueci a senha", "subtitle": "Insira abaixo o e-mail associado à sua conta. Enviaremos instruções para redefinir sua senha.", "confirmResetPassword": "Se o endereço de e-mail {email} está vinculado a uma conta na nossa loja, enviamos um e-mail de redefinição de senha. Verifique sua caixa de entrada e, caso não encontre, confira a pasta de spam.", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address" + } } }, "Register": { "title": "Registrar conta", "heading": "Nova conta", "cta": "Criar conta", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "firstNameRequired": "O primeiro nome é obrigatório", + "lastNameRequired": "O sobrenome é obrigatório", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "A senha é obrigatória", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "É obrigatório informar a cidade", + "countryRequired": "O país é obrigatório", + "stateRequired": "Estado/Província é obrigatório", + "postalCodeRequired": "O código postal é obrigatório" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", "EmptyState": { "title": "You don't have any addresses" + }, + "FieldErrors": { + "firstNameRequired": "O primeiro nome é obrigatório", + "lastNameRequired": "O sobrenome é obrigatório", + "addressLine1Required": "Address line 1 is required", + "cityRequired": "É obrigatório informar a cidade", + "countryRequired": "O país é obrigatório", + "stateRequired": "Estado/Província é obrigatório", + "postalCodeRequired": "O código postal é obrigatório" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Inscreva-se no nosso boletim informativo.", "marketingPreferencesUpdated": "Marketing preferences have been updated successfully!", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + }, + "FieldErrors": { + "firstNameRequired": "O primeiro nome é obrigatório", + "firstNameTooSmall": "First name must be at least 2 characters long", + "lastNameRequired": "O sobrenome é obrigatório", + "lastNameTooSmall": "Last name must be at least 2 characters long", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "currentPasswordRequired": "A senha atual é obrigatória", + "passwordRequired": "A senha é obrigatória", + "passwordTooSmall": "Password must be at least {minLength, plural, =1 {1 character} other {# characters}} long", + "passwordLowercaseRequired": "Password must contain at least one lowercase letter", + "passwordUppercaseRequired": "Password must contain at least one uppercase letter", + "passwordNumberRequired": "Password must contain at least {minNumbers, plural, =1 {one number} other {# numbers}}", + "passwordSpecialCharacterRequired": "Password must contain at least one special character", + "passwordsMustMatch": "The passwords do not match", + "confirmPasswordRequired": "Please confirm your password" } } }, @@ -306,6 +370,9 @@ "cartUpdateInProgress": "You have a cart update in progress. Are you sure you want to leave this page? Your changes may be lost.", "originalPrice": "Original price was {price}.", "currentPrice": "Current price is {price}.", + "quantityReadyToShip": "{quantity, number} ready to ship", + "quantityOnBackorder": "{quantity, number} will be backordered", + "partiallyAvailable": "Only {quantity, number} available", "CheckoutSummary": { "title": "Resumo", "subTotal": "Subtotal", @@ -335,7 +402,8 @@ "updateShipping": "Atualizar envio", "addShipping": "Adicionar envio", "cartNotFound": "An error occurred when retrieving your cart", - "noShippingOptions": "Não há opções de envio disponíveis para o seu endereço" + "noShippingOptions": "Não há opções de envio disponíveis para o seu endereço", + "countryRequired": "O país é obrigatório" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Outras informações", "currentStock": "{quantity, number} in stock", "backorderQuantity": "{quantity, number} will be on backorder", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Adicionar ao carrinho", "outOfStock": "Fora do estoque", @@ -427,13 +497,24 @@ "button": "Write a review", "title": "Write a review", "submit": "Enviar", + "cancel": "Cancelar", "ratingLabel": "Taxa", "titleLabel": "Título", "reviewLabel": "Avaliação", "nameLabel": "Nome", "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "FieldErrors": { + "titleRequired": "Title is required", + "authorRequired": "O nome é obrigatório", + "emailRequired": "Email is required", + "emailInvalid": "Please enter a valid email address", + "textRequired": "Review is required", + "ratingRequired": "Rating is required", + "ratingTooSmall": "Rating must be at least 1", + "ratingTooLarge": "Rating must be at most 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Fique por dentro das últimas novidades e ofertas da nossa loja.", "subscribedToNewsletter": "You have been subscribed to our newsletter!", "Errors": { - "invalidEmail": "Informe um endereço de email válido.", + "emailRequired": "Email is required", + "invalidEmail": "Please enter a valid email address", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." } }, @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "I acknowledge that this Gift Certificate will expire on {expiryDate}", "ctaLabel": "Adicionar ao carrinho", "Errors": { - "amountRequired": "Please select or enter a gift certificate amount.", - "amountInvalid": "Please select a valid gift certificate amount.", - "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}.", - "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later." + "amountRequired": "Please select or enter a gift certificate amount", + "amountInvalid": "Please select a valid gift certificate amount", + "amountOutOfRange": "Please enter an amount between {minAmount} and {maxAmount}", + "unexpectedSettingsError": "An unexpected error occurred while retrieving gift certificate settings. Please try again later.", + "senderNameRequired": "Your name is required", + "senderEmailRequired": "Your email is required", + "recipientNameRequired": "Recipient's name is required", + "recipientEmailRequired": "Recipient's email is required", + "emailInvalid": "Please enter a valid email address", + "checkboxRequired": "You must check this box to continue" } } } }, "Form": { - "optional": "Opcional" + "optional": "Opcional", + "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 4c7e300bb..539cab066 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -43,7 +43,17 @@ "newPassword": "Nytt lösenord", "confirmPassword": "Bekräfta lösenord", "passwordUpdated": "Lösenordet har uppdaterats!", - "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." + "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "FieldErrors": { + "passwordRequired": "Lösenord krävs", + "passwordTooSmall": "Lösenordet måste vara minst {minLength, plural, =1 {1 tecken} other {# tecken}} långt", + "passwordLowercaseRequired": "Lösenordet måste innehålla minst en liten bokstav", + "passwordUppercaseRequired": "Lösenordet måste innehålla minst en stor bokstav", + "passwordNumberRequired": "Lösenordet måste innehålla minst {minNumbers, plural, =1 {ett nummer} other {# nummer}}", + "passwordSpecialCharacterRequired": "Lösenordet måste innehålla minst ett specialtecken", + "passwordsMustMatch": "Lösenorden stämmer inte överens", + "confirmPasswordRequired": "Bekräfta ditt lösenord" + } }, "Login": { "title": "Logga in", @@ -56,6 +66,12 @@ "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", "passwordResetRequired": "Återställning av lösenord krävs. Kontrollera din e-post för instruktioner om hur du återställer ditt lösenord.", "invalidToken": "Din inloggningslänk är ogiltig eller har löpt ut. Försök logga in igen.", + "FieldErrors": { + "emailRequired": "E-postadress krävs", + "emailInvalid": "Ange en giltig e-postadress", + "passwordRequired": "Lösenord krävs", + "invalidInput": "Kontrollera din inmatning och försök igen." + }, "CreateAccount": { "title": "Nya kunder?", "accountBenefits": "Skapa ett konto hos oss, så kan du:", @@ -70,14 +86,36 @@ "title": "Glömt ditt lösenord", "subtitle": "Ange e-postadressen som är kopplad till ditt konto nedan. Vi skickar instruktioner för att återställa ditt lösenord.", "confirmResetPassword": "Om e-postadressen {email} är kopplad till ett konto i vår butik har vi skickat ett e-postmeddelande om återställning av lösenord. Kontrollera din inkorg och skräppostmapp om du inte ser meddelandet.", - "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." + "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "FieldErrors": { + "emailRequired": "E-postadress krävs", + "emailInvalid": "Ange en giltig e-postadress" + } } }, "Register": { "title": "Registrera konto", "heading": "Nytt konto", "cta": "Skapa konto", - "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." + "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "FieldErrors": { + "firstNameRequired": "Förnamn krävs", + "lastNameRequired": "Efternamn krävs", + "emailRequired": "E-postadress krävs", + "emailInvalid": "Ange en giltig e-postadress", + "passwordRequired": "Lösenord krävs", + "passwordTooSmall": "Lösenordet måste vara minst {minLength, plural, =1 {1 tecken} other {# tecken}} långt", + "passwordLowercaseRequired": "Lösenordet måste innehålla minst en liten bokstav", + "passwordUppercaseRequired": "Lösenordet måste innehålla minst en stor bokstav", + "passwordNumberRequired": "Lösenordet måste innehålla minst {minNumbers, plural, =1 {ett nummer} other {# nummer}}", + "passwordSpecialCharacterRequired": "Lösenordet måste innehålla minst ett specialtecken", + "passwordsMustMatch": "Lösenorden stämmer inte överens", + "addressLine1Required": "Adresslinje 1 krävs", + "cityRequired": "Stad krävs", + "countryRequired": "Land krävs", + "stateRequired": "Stat/provins krävs", + "postalCodeRequired": "Postnummer krävs" + } } }, "Faceted": { @@ -188,6 +226,15 @@ "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", "EmptyState": { "title": "Du har inga adresser" + }, + "FieldErrors": { + "firstNameRequired": "Förnamn krävs", + "lastNameRequired": "Efternamn krävs", + "addressLine1Required": "Adresslinje 1 krävs", + "cityRequired": "Stad krävs", + "countryRequired": "Land krävs", + "stateRequired": "Stat/provins krävs", + "postalCodeRequired": "Postnummer krävs" } }, "Settings": { @@ -205,6 +252,23 @@ "label": "Prenumerera på vårt nyhetsbrev.", "marketingPreferencesUpdated": "Marknadsföringsinställningarna har uppdaterats framgångsrikt!", "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." + }, + "FieldErrors": { + "firstNameRequired": "Förnamn krävs", + "firstNameTooSmall": "Förnamnet måste vara minst 2 tecken långt", + "lastNameRequired": "Efternamn krävs", + "lastNameTooSmall": "Efternamnet måste vara minst två tecken långt", + "emailRequired": "E-postadress krävs", + "emailInvalid": "Ange en giltig e-postadress", + "currentPasswordRequired": "Nuvarande lösenord krävs", + "passwordRequired": "Lösenord krävs", + "passwordTooSmall": "Lösenordet måste vara minst {minLength, plural, =1 {1 tecken} other {# tecken}} långt", + "passwordLowercaseRequired": "Lösenordet måste innehålla minst en liten bokstav", + "passwordUppercaseRequired": "Lösenordet måste innehålla minst en stor bokstav", + "passwordNumberRequired": "Lösenordet måste innehålla minst {minNumbers, plural, =1 {ett nummer} other {# nummer}}", + "passwordSpecialCharacterRequired": "Lösenordet måste innehålla minst ett specialtecken", + "passwordsMustMatch": "Lösenorden stämmer inte överens", + "confirmPasswordRequired": "Bekräfta ditt lösenord" } } }, @@ -304,8 +368,11 @@ "cartCombined": "Vi märkte att du hade varor sparade i en tidigare kundvagn, så vi har lagt till dem i din nuvarande kundvagn åt dig.", "cartRestored": "Du startade en kundvagn på en annan enhet, och vi har återställt den här så att du kan fortsätta där du slutade.", "cartUpdateInProgress": "Du har en pågående kundvagnsuppdatering. Är du säker på att du vill lämna den här sidan? Dina ändringar kan gå förlorade.", - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", + "originalPrice": "Ursprungligt pris var {price}.", + "currentPrice": "Nuvarande pris är {price}.", + "quantityReadyToShip": "{quantity, number} klara för leverans", + "quantityOnBackorder": "{quantity, number} kommer att vara restnoterade", + "partiallyAvailable": "Endast {quantity, number} tillgängligt", "CheckoutSummary": { "title": "Summering", "subTotal": "Delsumma", @@ -335,7 +402,8 @@ "updateShipping": "Uppdatera frakt", "addShipping": "Lägg till frakt", "cartNotFound": "Ett fel uppstod när er varukorg hämtades", - "noShippingOptions": "Det finns inga tillgängliga fraktalternativ för din adress" + "noShippingOptions": "Det finns inga tillgängliga fraktalternativ för din adress", + "countryRequired": "Land krävs" } }, "GiftCertificate": { @@ -395,6 +463,8 @@ "additionalInformation": "Ytterligare information", "currentStock": "{quantity, number} i lager", "backorderQuantity": "{quantity, number} kommer att vara restnoterade", + "loadingMoreImages": "Loading more images", + "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", "Submit": { "addToCart": "Lägg till i kundvagn", "outOfStock": "Ej i lager", @@ -427,13 +497,24 @@ "button": "Skriva en recension", "title": "Skriva en recension", "submit": "Skicka in", + "cancel": "Annullera", "ratingLabel": "Bedömning", "titleLabel": "Rubrik", "reviewLabel": "Recension", "nameLabel": "Namn", "emailLabel": "E-post", "successMessage": "Din recension har skickats in!", - "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." + "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "FieldErrors": { + "titleRequired": "Rubrik krävs", + "authorRequired": "Namn krävs", + "emailRequired": "E-postadress krävs", + "emailInvalid": "Ange en giltig e-postadress", + "textRequired": "Granskning krävs", + "ratingRequired": "Betyg krävs", + "ratingTooSmall": "Betyget måste vara minst 1", + "ratingTooLarge": "Betyget får vara högst 5" + } } } }, @@ -515,7 +596,8 @@ "description": "Håll dig uppdaterad med de senaste nyheterna och erbjudandena från vår butik.", "subscribedToNewsletter": "Du prenumererar nu på vårt nyhetsbrev!", "Errors": { - "invalidEmail": "Vänligen ange en giltig e-postadress", + "emailRequired": "E-postadress krävs", + "invalidEmail": "Ange en giltig e-postadress", "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." } }, @@ -559,9 +641,9 @@ } }, "Price": { - "originalPrice": "Original price was {price}.", - "currentPrice": "Current price is {price}.", - "range": "Price from {minValue} to {maxValue}." + "originalPrice": "Ursprungligt pris var {price}.", + "currentPrice": "Nuvarande pris är {price}.", + "range": "Pris från {minValue} till {maxValue}." } }, "GiftCertificates": { @@ -574,7 +656,7 @@ "title": "Kontrollera saldot", "description": "Du kan kontrollera saldot och få information om ditt presentkort genom att skriva in koden i rutan nedan.", "inputLabel": "Kod", - "inputPlaceholder": "xxx-xxx-xxx-xxx", + "inputPlaceholder": "XXX-XXX-XXX-XXX", "purchasedDateLabel": "Köpt", "senderLabel": "Från", "Errors": { @@ -607,15 +689,25 @@ "expiryCheckboxLabel": "Jag bekräftar att detta presentkort kommer att upphöra att gälla den {expiryDate}", "ctaLabel": "Lägg till i kundvagn", "Errors": { - "amountRequired": "Var god välj eller ange ett presentkortsbelopp.", - "amountInvalid": "Var god välj ett giltigt presentkortsbelopp.", - "amountOutOfRange": "Ange ett belopp mellan {minAmount} och {maxAmount}.", - "unexpectedSettingsError": "Ett oväntat fel inträffade när presentkortets inställningar hämtades. Försök igen senare." + "amountRequired": "Var god välj eller ange ett presentkortsbelopp", + "amountInvalid": "Välj ett giltigt presentkortsbelopp.", + "amountOutOfRange": "Ange ett belopp mellan {minAmount} och {maxAmount}", + "unexpectedSettingsError": "Ett oväntat fel inträffade när presentkortets inställningar hämtades. Försök igen senare.", + "senderNameRequired": "Ditt namn krävs", + "senderEmailRequired": "Din e-postadress krävs", + "recipientNameRequired": "Mottagarens namn är obligatoriskt", + "recipientEmailRequired": "Mottagarens e-postadress krävs", + "emailInvalid": "Ange en giltig e-postadress", + "checkboxRequired": "Du måste markera den här rutan för att fortsätta" } } } }, "Form": { - "optional": "frivillig" + "optional": "frivillig", + "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" + } } } From 7889cfa9cf937e1bf80f0c443efbfb16010b137e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 18 Feb 2026 12:25:42 -0600 Subject: [PATCH 14/17] fix(core): preview deployments now use correct metadataBase (#2890) * fix(core): preview deployments now use correct metadataBase * fix: lint issue * fix: use URL.canParse * chore: update changeset --- .changeset/new-onions-flash.md | 46 +++++++++++++++++++++++++++------- core/app/[locale]/layout.tsx | 13 +++++++++- core/lib/seo/canonical.ts | 5 +++- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/.changeset/new-onions-flash.md b/.changeset/new-onions-flash.md index 7bc9afcce..15e0f6ec6 100644 --- a/.changeset/new-onions-flash.md +++ b/.changeset/new-onions-flash.md @@ -2,25 +2,53 @@ "@bigcommerce/catalyst-core": patch --- -Add canonical URLs and hreflang alternates for SEO. Pages now set `alternates.canonical` and `alternates.languages` in `generateMetadata` via the new `getMetadataAlternates` helper in `core/lib/seo/canonical.ts`. The helper fetches the vanity URL via GraphQL (`site.settings.url.vanityUrl`) and is cached per request. The default locale uses no path prefix; other locales use `/{locale}/path`. The root locale layout sets `metadataBase` to the configured vanity URL so canonical URLs resolve correctly. +Add canonical URLs and hreflang alternates for SEO. Pages now set `alternates.canonical` and `alternates.languages` in `generateMetadata` via the new `getMetadataAlternates` helper in `core/lib/seo/canonical.ts`. The helper fetches the vanity URL via GraphQL (`site.settings.url.vanityUrl`) and is cached per request. The default locale uses no path prefix; other locales use `/{locale}/path`. The root locale layout sets `metadataBase` to the configured vanity URL so canonical URLs resolve correctly. On Vercel preview deployments (`VERCEL_ENV=preview`), `metadataBase` and canonical/hreflang URLs use `VERCEL_URL` instead of the production vanity URL to prevent preview environments from generating SEO metadata pointing to production. ## Migration steps ### Step 1: Root layout metadata base -The root locale layout now sets `metadataBase` from the vanity URL fetched via GraphQL. This is already included in the `RootLayoutMetadataQuery`. +The root locale layout now sets `metadataBase` from the vanity URL fetched via GraphQL. On Vercel preview deployments, `VERCEL_URL` is used instead so preview environments don't point to production. `URL.canParse` guards against malformed URLs. Update `core/app/[locale]/layout.tsx`: ```diff + const vanityUrl = data.site.settings?.url.vanityUrl; ++ ++ // Use preview deployment URL so metadataBase (canonical, og:url) points at the preview, not production. ++ let baseUrl: URL | undefined; ++ const previewUrl = ++ process.env.VERCEL_ENV === 'preview' ? `https://${process.env.VERCEL_URL}` : undefined; ++ ++ if (previewUrl && URL.canParse(previewUrl)) { ++ baseUrl = new URL(previewUrl); ++ } else if (vanityUrl && URL.canParse(vanityUrl)) { ++ baseUrl = new URL(vanityUrl); ++ } + return { -+ metadataBase: vanityUrl ? new URL(vanityUrl) : undefined, ++ metadataBase: baseUrl, title: { ``` -### Step 2: GraphQL fragment updates +### Step 2: Canonical/hreflang base URL for preview environments + +The `getMetadataAlternates` function in `core/lib/seo/canonical.ts` now checks for a Vercel preview URL before falling back to the GraphQL vanity URL. `URL.canParse` guards against malformed URLs. + +Update `core/lib/seo/canonical.ts`: + +```diff + export async function getMetadataAlternates(options: CanonicalUrlOptions) { + const { path, locale, includeAlternates = true } = options; + +- const baseUrl = await getVanityUrl(); ++ // Use preview deployment URL so canonical/hreflang URLs point at the preview, not production. ++ const previewUrl = ++ process.env.VERCEL_ENV === 'preview' ? `https://${process.env.VERCEL_URL}` : undefined; ++ const baseUrl = previewUrl && URL.canParse(previewUrl) ? previewUrl : await getVanityUrl(); +``` + +### Step 3: GraphQL fragment updates Add the `path` field to brand, blog post, and product queries so metadata can build canonical URLs. @@ -54,7 +82,7 @@ Update `core/app/[locale]/(default)/product/[slug]/page-data.ts` (in the metadat defaultImage { ``` -### Step 3: Page metadata alternates +### Step 4: Page metadata alternates Add the `getMetadataAlternates` import and set `alternates` in `generateMetadata` for each page. The function is async and must be awaited. Ensure `core/lib/seo/canonical.ts` exists (it is included in this release). @@ -94,7 +122,7 @@ For entity pages (product, category, brand, blog, blog post, webpage), add the i } ``` -### Step 4: Gift certificates pages +### Step 5: Gift certificates pages Update `core/app/[locale]/(default)/gift-certificates/page.tsx`: @@ -142,7 +170,7 @@ Add `generateMetadata` to `core/app/[locale]/(default)/gift-certificates/purchas + } ``` -### Step 5: Contact page +### Step 6: Contact page Update `core/app/[locale]/(default)/webpages/[id]/contact/page.tsx`: @@ -164,7 +192,7 @@ Update `core/app/[locale]/(default)/webpages/[id]/contact/page.tsx`: } ``` -### Step 6: Public wishlist page +### Step 7: Public wishlist page Update `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: @@ -181,7 +209,7 @@ Update `core/app/[locale]/(default)/wishlist/[token]/page.tsx`: } ``` -### Step 7: Compare page +### Step 8: Compare page Update `core/app/[locale]/(default)/compare/page.tsx`: diff --git a/core/app/[locale]/layout.tsx b/core/app/[locale]/layout.tsx index ae7642bd3..f0a08d7f3 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -73,8 +73,19 @@ export async function generateMetadata(): Promise { const vanityUrl = data.site.settings?.url.vanityUrl; + // Use preview deployment URL so metadataBase (canonical, og:url) points at the preview, not production. + let baseUrl: URL | undefined; + const previewUrl = + process.env.VERCEL_ENV === 'preview' ? `https://${process.env.VERCEL_URL}` : undefined; + + if (previewUrl && URL.canParse(previewUrl)) { + baseUrl = new URL(previewUrl); + } else if (vanityUrl && URL.canParse(vanityUrl)) { + baseUrl = new URL(vanityUrl); + } + return { - metadataBase: vanityUrl ? new URL(vanityUrl) : undefined, + metadataBase: baseUrl, title: { template: `%s - ${storeName}`, default: pageTitle || storeName, diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index 03f9cac3f..dd1573947 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -63,7 +63,10 @@ const getVanityUrl = cache(async () => { export async function getMetadataAlternates(options: CanonicalUrlOptions) { const { path, locale, includeAlternates = true } = options; - const baseUrl = await getVanityUrl(); + // Use preview deployment URL so canonical/hreflang URLs point at the preview, not production. + const previewUrl = + process.env.VERCEL_ENV === 'preview' ? `https://${process.env.VERCEL_URL}` : undefined; + const baseUrl = previewUrl && URL.canParse(previewUrl) ? previewUrl : await getVanityUrl(); const canonical = buildLocalizedUrl(baseUrl, path, locale); From a161583fee1b6e7e3f718c2e6f705ea822865025 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 18 Feb 2026 13:59:18 -0600 Subject: [PATCH 15/17] feat(ci): enable unlighthouse performance audits for vercel (#2882) * feat(ci): enable unlighthouse performance audits for vercel Re-enable the performance category in Unlighthouse with mitigations for hardware throttling: maxConcurrency=1 and samples=5 (median smoothing absorbs cold start outliers across all discovered pages without needing an explicit warm-up step). Rename artifacts with a "vercel" label for future cross-platform comparison. CATALYST-1768 Co-Authored-By: Claude Opus 4.6 * chore: revert samples to 3 * chore: add concurrency to speed tests * fix: target specific PLP and PDP pages to prevent wasted runtime * fix: change PLP category to bath * fix: expand report * chore: test concurrency and all routes * fix: fixed paths, multiple samples, with concurrency * fix: run all pages, concurrency enabled, 1 sample size * fix: add sample size of 3 * feat: add dynamic sampling * fix: split brands and categories * fix: update excluded routes * fix: excluded paths * fix: exclude other sites * fix: disable throttling * chore: update dynamic sampling * fix: remove brands from custom sampling since it should be picked up automatically * chore: limit concurrency * chore: remove concurrency limit, no visible change --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/regression-tests.yml | 2 +- unlighthouse.config.ts | 48 +++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml index ef81ebc18..3b4e8d4c1 100644 --- a/.github/workflows/regression-tests.yml +++ b/.github/workflows/regression-tests.yml @@ -38,6 +38,6 @@ jobs: if: failure() || success() uses: actions/upload-artifact@v4 with: - name: unlighthouse-${{ matrix.device }}-report + name: unlighthouse-vercel-${{ matrix.device }}-report path: './.unlighthouse/' include-hidden-files: 'true' diff --git a/unlighthouse.config.ts b/unlighthouse.config.ts index e323a87b5..796466a4c 100644 --- a/unlighthouse.config.ts +++ b/unlighthouse.config.ts @@ -1,27 +1,51 @@ - -import type { UserConfig } from 'unlighthouse'; +import type { UserConfig } from "unlighthouse"; export default { ci: { buildStatic: true, - // Disabling the budget so we can audit and fix the issues first + reporter: "jsonExpanded", budget: { // "best-practices": 100, // "accessibility": 100, // "seo": 100, + // performance: 80, + }, + }, + scanner: { + // Run each page multiple times and use the median to absorb cold start + // outliers across all discovered pages. + samples: 3, + dynamicSampling: 5, + exclude: [ + "/bundleb2b/", + "/invoices/", + "/bath/*/*", + "/garden/*/*", + "/kitchen/*/*", + "/publications/*/*", + "/early-access/*/*", + "/digital-test-product/", + "/blog/\\?tag=*", + ], + customSampling: { + "/smith-journal-13/|/dustpan-brush/|/utility-caddy/|/canvas-laundry-cart/|/laundry-detergent/|/tiered-wire-basket/|/oak-cheese-grater/|/1-l-le-parfait-jar/|/chemex-coffeemaker-3-cup/|/sample-able-brewing-system/|/orbit-terrarium-small/|/orbit-terrarium-large/|/fog-linen-chambray-towel-beige-stripe/|/zz-plant/": + { name: "PDP" }, + "/shop-all/|/bath/|/garden/|/kitchen/|/publications/|/early-access/": { + name: "PLP", + }, }, - }, + // Disable throttling to avoid issues with cold start and cold cache. + throttle: false, + }, lighthouseOptions: { - // Disabling performance tests because lighthouse utilizes hardware throttling. This affects concurrently running tests which might lead to false positives. - // The best way to truly measure performance is to use real user metrics – Vercel's Speed Insights is a great tool for that. - onlyCategories: ['best-practices', 'accessibility', 'seo'], + onlyCategories: ["best-practices", "accessibility", "seo", "performance"], skipAudits: [ // Disabling `is-crawlable` as it's more relevant for production sites. - 'is-crawlable', + "is-crawlable", // Disabling third-party cookies because the only third-party cookies we have is provided through Cloudflare for our CDN, which is not relevant for our audits. - 'third-party-cookies', + "third-party-cookies", // Disabling inspector issues as it's only providing third-party cookie issues, which are not relevant for our audits. - 'inspector-issues', - ] - } + "inspector-issues", + ], + }, } satisfies UserConfig; From 46ee3de640f030be56111c668102bbeeb961b4a4 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 25 Feb 2026 09:02:07 -0600 Subject: [PATCH 16/17] fix(core): conditionally include optional SEO metadata (#2898) * fix(core): conditionally include optional SEO metadata * fix: remove new line in product meta description --- .changeset/quiet-tags-spread.md | 77 +++++++++++++++++++ .../(default)/(faceted)/brand/[slug]/page.tsx | 6 +- .../(faceted)/category/[slug]/page.tsx | 4 +- .../[locale]/(default)/blog/[blogId]/page.tsx | 4 +- core/app/[locale]/(default)/blog/page.tsx | 10 ++- .../(default)/product/[slug]/page.tsx | 17 ++-- .../(default)/webpages/[id]/contact/page.tsx | 8 +- .../(default)/webpages/[id]/normal/page.tsx | 4 +- 8 files changed, 102 insertions(+), 28 deletions(-) create mode 100644 .changeset/quiet-tags-spread.md diff --git a/.changeset/quiet-tags-spread.md b/.changeset/quiet-tags-spread.md new file mode 100644 index 000000000..7a3a75f8f --- /dev/null +++ b/.changeset/quiet-tags-spread.md @@ -0,0 +1,77 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Conditionally include optional SEO metadata fields in `generateMetadata` across page files. Fields `description`, `keywords`, `alternates`, and `openGraph` are now only included in the returned metadata object when they have a value, using spread syntax (`...(value && { key: value })`). Previously, these fields were always set — potentially assigning `null` or an empty string — which could cause Next.js to render empty `` tags. + +## Migration steps + +Update `generateMetadata` in the following pages to use conditional spread syntax for optional metadata fields: + +### brand, category, webpages (contact + normal) + +```diff + return { + title: pageTitle || entity.name, +- description: metaDescription, +- keywords: metaKeywords ? metaKeywords.split(',') : null, ++ ...(metaDescription && { description: metaDescription }), ++ ...(metaKeywords && { keywords: metaKeywords.split(',') }), + }; +``` + +For `brand/[slug]/page.tsx`, also guard the `alternates` field: + +```diff +- alternates: await getMetadataAlternates({ path: brand.path, locale }), ++ ...(brand.path && { alternates: await getMetadataAlternates({ path: brand.path, locale }) }), +``` + +### blog/[blogId]/page.tsx + +```diff + return { + title: pageTitle || blogPost.name, +- description: metaDescription, +- keywords: metaKeywords ? metaKeywords.split(',') : null, ++ ...(metaDescription && { description: metaDescription }), ++ ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(blogPost.path && { + alternates: await getMetadataAlternates({ path: blogPost.path, locale }), + }), + }; +``` + +### product/[slug]/page.tsx + +```diff +- keywords: metaKeywords ? metaKeywords.split(',') : null, ++ ...(metaKeywords && { keywords: metaKeywords.split(',') }), +- openGraph: url +- ? { +- images: [{ url, alt }], +- } +- : null, ++ ...(url && { openGraph: { images: [{ url, alt }] } }), +``` + +### blog/page.tsx + +Extract the description to a variable and spread conditionally: + +```diff ++ const description = ++ blog?.description && blog.description.length > 150 ++ ? `${blog.description.substring(0, 150)}...` ++ : blog?.description; ++ + return { + title: blog?.name ?? t('title'), +- description: +- blog?.description && blog.description.length > 150 +- ? `${blog.description.substring(0, 150)}...` +- : blog?.description, ++ ...(description && { description }), + ...(blog?.path && { alternates: await getMetadataAlternates({ path: blog.path, locale }) }), + }; +``` diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 163419493..4278def31 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -83,9 +83,9 @@ export async function generateMetadata(props: Props): Promise { return { title: pageTitle || brand.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, - alternates: await getMetadataAlternates({ path: brand.path, locale }), + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(brand.path && { alternates: await getMetadataAlternates({ path: brand.path, locale }) }), }; } diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index 3471282dd..ee143281b 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -88,8 +88,8 @@ export async function generateMetadata(props: Props): Promise { return { title: pageTitle || category.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), ...(categoryPath && { alternates: await getMetadataAlternates({ path: categoryPath, locale }), }), diff --git a/core/app/[locale]/(default)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index 35ba4ffa9..6b8da4501 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -34,8 +34,8 @@ export async function generateMetadata({ params }: Props): Promise { return { title: pageTitle || blogPost.name, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), ...(blogPost.path && { alternates: await getMetadataAlternates({ path: blogPost.path, locale }), }), diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index e960967c5..1b8d90cf9 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -31,12 +31,14 @@ export async function generateMetadata({ params }: Props): Promise { const t = await getTranslations({ locale, namespace: 'Blog' }); const blog = await getBlog(); + const description = + blog?.description && blog.description.length > 150 + ? `${blog.description.substring(0, 150)}...` + : blog?.description; + return { title: blog?.name ?? t('title'), - description: - blog?.description && blog.description.length > 150 - ? `${blog.description.substring(0, 150)}...` - : blog?.description, + ...(description && { description }), ...(blog?.path && { alternates: await getMetadataAlternates({ path: blog.path, locale }) }), }; } diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 5f31e4dad..c1e8a96bf 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -55,19 +55,12 @@ export async function generateMetadata({ params }: Props): Promise { return { title: pageTitle || product.name, - description: metaDescription || `${product.plainTextDescription.slice(0, 150)}...`, - keywords: metaKeywords ? metaKeywords.split(',') : null, + description: + metaDescription || + `${product.plainTextDescription.replaceAll(/\s+/g, ' ').trim().slice(0, 150)}...`, + ...(metaKeywords && { keywords: metaKeywords.split(',') }), alternates: await getMetadataAlternates({ path: product.path, locale }), - openGraph: url - ? { - images: [ - { - url, - alt, - }, - ], - } - : null, + ...(url && { openGraph: { images: [{ url, alt }] } }), }; } diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index e053978db..b8d1f2e75 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -160,9 +160,11 @@ export async function generateMetadata({ params }: Props): Promise { return { title: pageTitle || webpage.title, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, - alternates: await getMetadataAlternates({ path: webpage.path, locale }), + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(webpage.path && { + alternates: await getMetadataAlternates({ path: webpage.path, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index 6406c381d..a222ea18c 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -67,8 +67,8 @@ export async function generateMetadata({ params }: Props): Promise { return { title: pageTitle || webpage.title, - description: metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...(metaDescription && { description: metaDescription }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), ...(pagePath && { alternates: await getMetadataAlternates({ path: pagePath, locale }) }), }; } From 8d128fc75006ef8ab330e3597bfcf15cdc70da71 Mon Sep 17 00:00:00 2001 From: bc-svc-local <102379007+bc-svc-local@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:19:41 +0100 Subject: [PATCH 17/17] Update translations (#2897) * feat(other): LOCAL-1444 delivery translation * chore(core): create translations patch --------- Co-authored-by: bc-svc-local --- .changeset/translations-patch-2487e2ac.md | 5 +++++ core/messages/da.json | 4 ++-- core/messages/de.json | 4 ++-- core/messages/es-419.json | 4 ++-- core/messages/es-AR.json | 4 ++-- core/messages/es-CL.json | 4 ++-- core/messages/es-CO.json | 4 ++-- core/messages/es-LA.json | 4 ++-- core/messages/es-MX.json | 4 ++-- core/messages/es-PE.json | 4 ++-- core/messages/es.json | 4 ++-- core/messages/fr.json | 4 ++-- core/messages/it.json | 4 ++-- core/messages/ja.json | 4 ++-- core/messages/nl.json | 4 ++-- core/messages/no.json | 4 ++-- core/messages/sv.json | 4 ++-- 17 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 .changeset/translations-patch-2487e2ac.md diff --git a/.changeset/translations-patch-2487e2ac.md b/.changeset/translations-patch-2487e2ac.md new file mode 100644 index 000000000..ad17b2636 --- /dev/null +++ b/.changeset/translations-patch-2487e2ac.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": patch +--- + +Update translations. diff --git a/core/messages/da.json b/core/messages/da.json index 0f571c362..8bd50c727 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -463,8 +463,8 @@ "additionalInformation": "Yderligere oplysninger", "currentStock": "{antal, number} på lager", "backorderQuantity": "{antal, number} vil være i restordre", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Indlæser flere billeder", + "imagesLoaded": "{count, plural, =1 {1 yderligere billede indlæst} other {# yderligere billeder indlæst}}", "Submit": { "addToCart": "Føj til kurv", "outOfStock": "Udsolgt", diff --git a/core/messages/de.json b/core/messages/de.json index 8a7db06ad..1a83921ca 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -463,8 +463,8 @@ "additionalInformation": "Weitere Informationen", "currentStock": "{quantity, number} auf Lager", "backorderQuantity": "{quantity, number} wird nachbestellt", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Weitere Bilder werden geladen", + "imagesLoaded": "{count, plural, =1 {1 weiteres Bild geladen, } other {# weitere Bilder geladen, }}", "Submit": { "addToCart": "Zum Warenkorb hinzufügen", "outOfStock": "Kein Lagerbestand", diff --git a/core/messages/es-419.json b/core/messages/es-419.json index 93e494019..43924c445 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-AR.json b/core/messages/es-AR.json index 93e494019..43924c445 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-CL.json b/core/messages/es-CL.json index 93e494019..43924c445 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-CO.json b/core/messages/es-CO.json index 93e494019..43924c445 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-LA.json b/core/messages/es-LA.json index 93e494019..43924c445 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-MX.json b/core/messages/es-MX.json index 93e494019..43924c445 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-PE.json b/core/messages/es-PE.json index 93e494019..43924c445 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es.json b/core/messages/es.json index 507e02c4b..edd2b00ab 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -463,8 +463,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en existencias", "backorderQuantity": "{cantidad, número} en pedidos pendientes", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Cargando más imágenes", + "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# imágenes más cargadas}}", "Submit": { "addToCart": "Añadir al carrito", "outOfStock": "Sin existencias", diff --git a/core/messages/fr.json b/core/messages/fr.json index 9e3ba28a7..cc4aea4b5 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -463,8 +463,8 @@ "additionalInformation": "Informations supplémentaires", "currentStock": "{quantity, number} en stock", "backorderQuantity": "{quantité, nombre} en attente de réapprovisionnement", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Chargement de plus d'images", + "imagesLoaded": "{count, plural, =1 {1 image supplémentaire chargée} other {# images supplémentaires chargées}}", "Submit": { "addToCart": "Ajouter au panier", "outOfStock": "En rupture de stock", diff --git a/core/messages/it.json b/core/messages/it.json index 9d49e830c..e47aa82c0 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -463,8 +463,8 @@ "additionalInformation": "Informazioni aggiuntive", "currentStock": "{quantità, numero} in magazzino", "backorderQuantity": "{quantity, number} sarà in arretrato", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Caricamento di altre immagini", + "imagesLoaded": "{count, plural, =1 {1 altra immagine caricata} other {# altre immagini caricate}}", "Submit": { "addToCart": "Aggiungi al carrello", "outOfStock": "Esaurito", diff --git a/core/messages/ja.json b/core/messages/ja.json index ff94c4325..5a2fa86d4 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -463,8 +463,8 @@ "additionalInformation": "追加情報", "currentStock": "{quantity, number} 個の在庫あり", "backorderQuantity": "{quantity, number} はバックオーダーになります", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "さらに画像を読み込んでいます", + "imagesLoaded": "{count, plural, =1 {1 枚の画像が読み込まれました} other {#枚の画像が読み込まれました}}", "Submit": { "addToCart": "カートに追加", "outOfStock": "品切れ", diff --git a/core/messages/nl.json b/core/messages/nl.json index fc30faaa6..61bca344e 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -463,8 +463,8 @@ "additionalInformation": "Aanvullende informatie", "currentStock": "{quantity, number} op voorraad", "backorderQuantity": "{hoeveelheid, aantal} staat in backorder", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Meer afbeeldingen laden", + "imagesLoaded": "{count, plural, =1 {1 afbeelding geladen} other {# afbeeldingen geladen}}", "Submit": { "addToCart": "Toevoegen aan winkelmandje", "outOfStock": "Niet op voorraad", diff --git a/core/messages/no.json b/core/messages/no.json index 99b29e946..1197c07cf 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -463,8 +463,8 @@ "additionalInformation": "Mer informasjon", "currentStock": "{antall, nummer} på lager", "backorderQuantity": "{antall, nummer} vil være restordre", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Laster inn flere bilder", + "imagesLoaded": "{antall, flertall, =1 {1 bilde til ble lastet inn} other {# flere bilder ble lastet inn}}", "Submit": { "addToCart": "Legg i handlekurv", "outOfStock": "Utsolgt", diff --git a/core/messages/sv.json b/core/messages/sv.json index 539cab066..d16362d17 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -463,8 +463,8 @@ "additionalInformation": "Ytterligare information", "currentStock": "{quantity, number} i lager", "backorderQuantity": "{quantity, number} kommer att vara restnoterade", - "loadingMoreImages": "Loading more images", - "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "loadingMoreImages": "Laddar fler bilder", + "imagesLoaded": "{count, plural, =1 {1 till bild har laddats} other {# till bilder har laddats}}", "Submit": { "addToCart": "Lägg till i kundvagn", "outOfStock": "Ej i lager",