diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 154de2b562..1bd4d8228b 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/deploy.yml b/.github/workflows/deploy.yml index 9039e24adb..2e8edf081e 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" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..657f562574 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,105 @@ +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: | + 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: + 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/regression-tests.yml b/.github/workflows/regression-tests.yml index ef81ebc18e..3b4e8d4c19 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/CONTRIBUTING.md b/CONTRIBUTING.md index d69b24dfdb..29c9e04c6f 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). @@ -60,6 +66,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 +111,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 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 128b2e3b8e..12bbaf3883 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 0000000000..43a72f2d3a --- /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 944f091c6c..1e7e251a6a 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 1aeaeb9b58..bf8ebf1357 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 ( { return { title: makeswiftMetadata?.title || pageTitle || brand.name, - description: makeswiftMetadata?.description || metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.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 62594fa3e4..3742f4e96d 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -16,6 +16,7 @@ import { productCardTransformer } from '~/data-transformers/product-card-transfo import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { Slot } from '~/lib/makeswift/slot'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { MAX_COMPARE_LIMIT } from '../../../compare/page-data'; import { getCompareProducts } from '../../fetch-compare-products'; @@ -86,10 +87,18 @@ 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: makeswiftMetadata?.title || pageTitle || category.name, - description: makeswiftMetadata?.description || metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(categoryPath && { + alternates: await getMetadataAlternates({ path: categoryPath, locale }), + }), }; } diff --git a/core/app/[locale]/(default)/account/settings/_actions/change-password.ts b/core/app/[locale]/(default)/account/settings/_actions/change-password.ts index 3a5df942d9..e781cc140b 100644 --- a/core/app/[locale]/(default)/account/settings/_actions/change-password.ts +++ b/core/app/[locale]/(default)/account/settings/_actions/change-password.ts @@ -3,9 +3,9 @@ import { BigCommerceGQLError } from '@bigcommerce/catalyst-client'; import { parseWithZod } from '@conform-to/zod'; import { getTranslations } from 'next-intl/server'; +import { z } from 'zod'; import { ChangePasswordAction } from '@/vibes/soul/sections/account-settings/change-password-form'; -import { changePasswordSchema } from '@/vibes/soul/sections/account-settings/schema'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; @@ -34,11 +34,15 @@ const CustomerChangePasswordMutation = graphql(` } `); +const schema = z.object({ + currentPassword: z.string().trim(), + password: z.string(), +}); + export const changePassword: ChangePasswordAction = async (prevState, formData) => { 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 f0db7b0f6a..136ef5c991 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 6d074e9431..cad145dc6f 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)/blog/[blogId]/page.tsx b/core/app/[locale]/(default)/blog/[blogId]/page.tsx index a97945f9e3..df2df54e73 100644 --- a/core/app/[locale]/(default)/blog/[blogId]/page.tsx +++ b/core/app/[locale]/(default)/blog/[blogId]/page.tsx @@ -6,6 +6,7 @@ import { cache } from 'react'; import { BlogPostContent, BlogPostContentBlogPost } from '@/vibes/soul/sections/blog-post-content'; import { Breadcrumb } from '@/vibes/soul/sections/breadcrumbs'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlogPageData } from './page-data'; @@ -35,8 +36,13 @@ export async function generateMetadata({ params }: Props): Promise { return { title: makeswiftMetadata?.title || pageTitle || blogPost.name, - description: makeswiftMetadata?.description || metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.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 551cb74134..8b2fe4af18 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -8,6 +8,7 @@ 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 { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getBlog, getBlogPosts } from './page-data'; @@ -32,13 +33,16 @@ export async function generateMetadata({ params }: Props): Promise { const blog = await getBlog(); const makeswiftMetadata = await getMakeswiftPageMetadata({ path: '/blog', locale }); + const description = + makeswiftMetadata?.description || + (blog?.description && blog.description.length > 150 + ? `${blog.description.substring(0, 150)}...` + : blog?.description); + return { title: makeswiftMetadata?.title || blog?.name || t('title'), - description: - makeswiftMetadata?.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)/cart/_actions/update-shipping-info.ts b/core/app/[locale]/(default)/cart/_actions/update-shipping-info.ts index bb1c1d34a0..662ba0f7a6 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)/cart/page-data.ts b/core/app/[locale]/(default)/cart/page-data.ts index e1758e1a7e..c6e47636dc 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 9f0568603d..fca7934200 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -129,6 +129,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, @@ -169,6 +210,7 @@ export default async function Cart({ params }: Props) { selectedOptions: item.selectedOptions, productEntityId: item.productEntityId, variantEntityId: item.variantEntityId, + inventoryMessages, }; }); @@ -196,12 +238,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 = diff --git a/core/app/[locale]/(default)/compare/page.tsx b/core/app/[locale]/(default)/compare/page.tsx index 068645588f..33adca4aed 100644 --- a/core/app/[locale]/(default)/compare/page.tsx +++ b/core/app/[locale]/(default)/compare/page.tsx @@ -9,6 +9,7 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; import { CompareAnalyticsProvider } from './_components/compare-analytics-provider'; @@ -46,7 +47,8 @@ export async function generateMetadata({ params }: Props): Promise { return { title: makeswiftMetadata?.title || t('title'), - description: makeswiftMetadata?.description || undefined, + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + 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 2dde4fbee5..88c4c4fe21 100644 --- a/core/app/[locale]/(default)/gift-certificates/balance/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/balance/page.tsx @@ -5,6 +5,7 @@ import { GiftCertificateCheckBalanceSection } from '@/vibes/soul/sections/gift-c import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getGiftCertificatesData } from '../page-data'; @@ -25,7 +26,8 @@ export async function generateMetadata({ params }: Props): Promise { return { title: makeswiftMetadata?.title || t('title') || 'Gift certificates - Check balance', - description: makeswiftMetadata?.description || undefined, + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + 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 5ea66db295..9f95690e45 100644 --- a/core/app/[locale]/(default)/gift-certificates/page.tsx +++ b/core/app/[locale]/(default)/gift-certificates/page.tsx @@ -5,6 +5,7 @@ import { GiftCertificatesSection } from '@/vibes/soul/sections/gift-certificates import { redirect } from '~/i18n/routing'; import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { getGiftCertificatesData } from './page-data'; @@ -20,7 +21,8 @@ export async function generateMetadata({ params }: Props): Promise { return { title: makeswiftMetadata?.title || t('title') || 'Gift certificates', - description: makeswiftMetadata?.description || undefined, + ...(makeswiftMetadata?.description && { description: makeswiftMetadata.description }), + alternates: await getMetadataAlternates({ path: '/gift-certificates', locale }), }; } 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 10f05d1782..49047ba143 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 07edaab6a9..29e4158c75 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,23 +17,34 @@ 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, - 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 +52,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 +91,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 +103,7 @@ function getFields( { type: 'select', name: 'amount', - label: `${t('Purchase.Form.amountLabel')} *`, + label: t('Purchase.Form.amountLabel'), defaultValue: '0', options: [ { diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 907f31fe06..2459e2cf26 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -2,6 +2,7 @@ import { Metadata } from 'next'; import { locales } from '~/i18n/locales'; import { getMakeswiftPageMetadata, Page as MakeswiftPage } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; interface Params { locale: string; @@ -15,7 +16,11 @@ export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const metadata = await getMakeswiftPageMetadata({ path: '/', locale }); - return metadata ?? {}; + return { + ...(metadata?.title != null && { title: metadata.title }), + ...(metadata?.description != null && { description: metadata.description }), + alternates: await getMetadataAlternates({ path: '/', locale }), + }; } export function generateStaticParams(): Params[] { 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 0000000000..4d52edc9bd --- /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/product-review-schema/product-review-schema.tsx b/core/app/[locale]/(default)/product/[slug]/_components/product-review-schema/product-review-schema.tsx index 15f1cd00c4..21917a35e8 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/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 4bfc58e359..6429de74be 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 abfcff0777..c61334e8bc 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) @@ -211,7 +212,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 +248,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; @@ -275,7 +276,12 @@ const StreamableProductQuery = graphql( optionValueIds: $optionValueIds useDefaultOptionSelections: $useDefaultOptionSelections ) { - images { + entityId + images(first: 12) { + pageInfo { + hasNextPage + endCursor + } edges { node { altText @@ -306,6 +312,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 @@ -320,25 +356,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 14f4c820ab..dd0268116d 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -13,8 +13,10 @@ import { productOptionsTransformer } from '~/data-transformers/product-options-t import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { ProductDetail } from '~/lib/makeswift/components/product-detail'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; 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'; @@ -28,7 +30,8 @@ import { getProductPricingAndRelatedProducts, getStreamableInventorySettingsQuery, getStreamableProduct, - getStreamableProductVariant, + getStreamableProductInventory, + getStreamableProductVariantInventory, } from './page-data'; interface Props { @@ -58,18 +61,10 @@ export async function generateMetadata({ params }: Props): Promise { description: makeswiftMetadata?.description || metaDescription || - `${product.plainTextDescription.slice(0, 150)}...`, - keywords: metaKeywords ? metaKeywords.split(',') : null, - openGraph: url - ? { - images: [ - { - url, - alt, - }, - ], - } - : null, + `${product.plainTextDescription.replaceAll(/\s+/g, ' ').trim().slice(0, 150)}...`, + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + alternates: await getMetadataAlternates({ path: product.path, locale }), + ...(url && { openGraph: { images: [{ url, alt }] } }), }; } @@ -123,8 +118,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; @@ -135,7 +144,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; @@ -188,13 +197,16 @@ 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 () => { - const product = await streamableProduct; + const product = await streamableProductInventory; if (product.availabilityV2.status === 'Unavailable') { return t('ProductDetails.Submit.unavailable'); @@ -212,7 +224,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 +271,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 +361,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, ]); @@ -552,6 +564,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/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index f9b167e630..04468930b0 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -13,6 +13,7 @@ import { truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPage, WebPageContent } from '../_components/web-page'; @@ -160,8 +161,13 @@ export async function generateMetadata({ params }: Props): Promise { return { title: makeswiftMetadata?.title || pageTitle || webpage.title, - description: makeswiftMetadata?.description || metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.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 7e56fe021d..36c0aeaf7c 100644 --- a/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -10,6 +10,7 @@ import { truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPageContent, WebPage as WebPageData } from '../_components/web-page'; @@ -64,10 +65,16 @@ export async function generateMetadata({ params }: Props): Promise { const makeswiftMetadata = await getMakeswiftPageMetadata({ path: webpage.path, locale }); const { pageTitle, metaDescription, metaKeywords } = webpage.seo; + // Get the path from the last breadcrumb + const pagePath = webpage.breadcrumbs[webpage.breadcrumbs.length - 1]?.href; + return { title: makeswiftMetadata?.title || pageTitle || webpage.title, - description: makeswiftMetadata?.description || metaDescription, - keywords: metaKeywords ? metaKeywords.split(',') : null, + ...((makeswiftMetadata?.description || metaDescription) && { + description: makeswiftMetadata?.description || metaDescription, + }), + ...(metaKeywords && { keywords: metaKeywords.split(',') }), + ...(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 0ee9fc40bc..e6fe52378f 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 4061aa3386..a2e82786d1 100644 --- a/core/app/[locale]/layout.tsx +++ b/core/app/[locale]/layout.tsx @@ -35,6 +35,9 @@ const RootLayoutMetadataQuery = graphql( query RootLayoutMetadataQuery { site { settings { + url { + vanityUrl + } privacy { cookieConsentEnabled privacyPolicyUrl @@ -73,7 +76,21 @@ export async function generateMetadata(): Promise { const { pageTitle, metaDescription, metaKeywords } = data.site.settings?.seo || {}; + 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: baseUrl, title: { template: `%s - ${storeName}`, default: pageTitle || storeName, diff --git a/core/components/subscribe/_actions/subscribe.ts b/core/components/subscribe/_actions/subscribe.ts index 62a82ec774..f4470c138d 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 0000000000..a395d5c41c --- /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/lib/seo/canonical.ts b/core/lib/seo/canonical.ts new file mode 100644 index 0000000000..dd1573947a --- /dev/null +++ b/core/lib/seo/canonical.ts @@ -0,0 +1,102 @@ +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; + + // 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); + + 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; +} diff --git a/core/messages/da.json b/core/messages/da.json index fc255abaa4..49c0f8334f 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Yderligere oplysninger", "currentStock": "{antal, number} på lager", "backorderQuantity": "{antal, number} vil være i restordre", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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 564f97cb3a..8ffa5e7126 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Weitere Informationen", "currentStock": "{quantity, number} auf Lager", "backorderQuantity": "{quantity, number} wird nachbestellt", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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/en.json b/core/messages/en.json index 156b76ab6e..021be9f102 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" } } }, @@ -362,6 +426,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", @@ -391,7 +458,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": { @@ -451,6 +519,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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -663,15 +745,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/messages/es-419.json b/core/messages/es-419.json index 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 89391c6cac..7da9edb9a7 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en stock", "backorderQuantity": "{cantidad, número} estará en espera", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 ebd1050608..20978eb72e 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Información adicional", "currentStock": "{cantidad, número} en existencias", "backorderQuantity": "{cantidad, número} en pedidos pendientes", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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 1b490794f1..fdb811e478 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Informations supplémentaires", "currentStock": "{quantity, number} en stock", "backorderQuantity": "{quantité, nombre} en attente de réapprovisionnement", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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 1cc2ce02ba..df052c3b8d 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Informazioni aggiuntive", "currentStock": "{quantità, numero} in magazzino", "backorderQuantity": "{quantity, number} sarà in arretrato", + "loadingMoreImages": "Caricamento di altre immagini", + "imagesLoaded": "{count, plural, =1 {1 altra immagine caricata} other {# altre immagini caricate}}", "Submit": { "addToCart": "Aggiungi al carrello", "outOfStock": "Esaurito", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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 2571eb7ec7..3baada44d7 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": "パスワードを確認してください" } } }, @@ -360,8 +424,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": "小計", @@ -391,7 +458,8 @@ "updateShipping": "配送情報を更新", "addShipping": "配送を追加", "cartNotFound": "カートの取得中にエラーが発生しました", - "noShippingOptions": "ご指定の住所で利用できる配送オプションはありません" + "noShippingOptions": "ご指定の住所で利用できる配送オプションはありません", + "countryRequired": "国名コードが必要です" } }, "GiftCertificate": { @@ -451,6 +519,8 @@ "additionalInformation": "追加情報", "currentStock": "{quantity, number} 個の在庫あり", "backorderQuantity": "{quantity, number} はバックオーダーになります", + "loadingMoreImages": "さらに画像を読み込んでいます", + "imagesLoaded": "{count, plural, =1 {1 枚の画像が読み込まれました} other {#枚の画像が読み込まれました}}", "Submit": { "addToCart": "カートに追加", "outOfStock": "品切れ", @@ -483,13 +553,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以下でなければなりません" + } } } }, @@ -571,7 +652,8 @@ "description": "当店の最新ニュースやオファーをぜひチェックしてください。", "subscribedToNewsletter": "ニュースレターを購読されました。", "Errors": { - "invalidEmail": "有効なメールアドレスを入力してください。", + "emailRequired": "メールアドレスは必須です", + "invalidEmail": "有効なメールアドレスを入力してください", "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" } }, @@ -615,9 +697,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": { @@ -663,15 +745,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 8010975687..9f063d3234 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Aanvullende informatie", "currentStock": "{quantity, number} op voorraad", "backorderQuantity": "{hoeveelheid, aantal} staat in backorder", + "loadingMoreImages": "Meer afbeeldingen laden", + "imagesLoaded": "{count, plural, =1 {1 afbeelding geladen} other {# afbeeldingen geladen}}", "Submit": { "addToCart": "Toevoegen aan winkelmandje", "outOfStock": "Niet op voorraad", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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 69c50a6c32..488addcff4 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Mer informasjon", "currentStock": "{antall, nummer} på lager", "backorderQuantity": "{antall, nummer} vil være restordre", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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 f405688559..e5a241ed13 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" } } }, @@ -362,6 +426,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", @@ -391,7 +458,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": { @@ -451,6 +519,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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -663,15 +745,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 541a3145a8..b931389eaf 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" } } }, @@ -362,6 +426,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", @@ -391,7 +458,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": { @@ -451,6 +519,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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -663,15 +745,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 541a3145a8..b931389eaf 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" } } }, @@ -362,6 +426,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", @@ -391,7 +458,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": { @@ -451,6 +519,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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -663,15 +745,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 ef6deea088..0f64b9ca46 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" } } }, @@ -360,8 +424,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", @@ -391,7 +458,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": { @@ -451,6 +519,8 @@ "additionalInformation": "Ytterligare information", "currentStock": "{quantity, number} i lager", "backorderQuantity": "{quantity, number} kommer att vara restnoterade", + "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", @@ -483,13 +553,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" + } } } }, @@ -571,7 +652,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." } }, @@ -615,9 +697,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": { @@ -630,7 +712,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": { @@ -663,15 +745,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" + } } } diff --git a/core/package.json b/core/package.json index 2f558ffd64..b0e62972d6 100644 --- a/core/package.json +++ b/core/package.json @@ -47,13 +47,13 @@ "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", - "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/core/tests/ui/e2e/auth/forgot-password.spec.ts b/core/tests/ui/e2e/auth/forgot-password.spec.ts index a8a68faed6..32cafede58 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 620dff9d0c..53c21b1d57 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 4b92f5d2b0..f14f19a76b 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/carousel/index.tsx b/core/vibes/soul/primitives/carousel/index.tsx index 701f766355..11e21f8044 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/primitives/inline-email-form/index.tsx b/core/vibes/soul/primitives/inline-email-form/index.tsx index b6f848ce58..9124585fde 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 00c2c70420..57aa5f018c 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 c2323516d1..809a01e64f 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 8c6377d621..d8e52712c4 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 7ce11b8112..5fe66feea2 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 3e7f9bf58f..44bfb6bd39 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 a16189e619..0f4cc92a60 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 906e2a8ae6..eef829ef97 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/client.tsx b/core/vibes/soul/sections/cart/client.tsx index cbe49988ec..8825d06b3b 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} + + )} - ); 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 8b16625778..f00d7f0660 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 8c39d9107f..bbbadffe08 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 e67a525020..50d35712e1 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 5fc786474b..554f92d4df 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 2a0aa8d775..3de3df6b35 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 c777b6589b..d685b379b8 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 71a6104159..8617d55834 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({ >; + 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 4567bc5b80..be9d0db905 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/reset-password-section/index.tsx b/core/vibes/soul/sections/reset-password-section/index.tsx index 2a7df803a2..614cbd1ee6 100644 --- a/core/vibes/soul/sections/reset-password-section/index.tsx +++ b/core/vibes/soul/sections/reset-password-section/index.tsx @@ -1,3 +1,5 @@ +import { PasswordComplexitySettings } from '@/vibes/soul/form/dynamic-form/schema'; + import { ResetPasswordAction, ResetPasswordForm } from './reset-password-form'; interface Props { @@ -7,6 +9,7 @@ interface Props { submitLabel?: string; newPasswordLabel?: string; confirmPasswordLabel?: string; + passwordComplexitySettings?: PasswordComplexitySettings | null; } export function ResetPasswordSection({ @@ -15,6 +18,7 @@ export function ResetPasswordSection({ submitLabel, newPasswordLabel, confirmPasswordLabel, + passwordComplexitySettings, action, }: Props) { return ( @@ -27,6 +31,7 @@ export function ResetPasswordSection({ action={action} confirmPasswordLabel={confirmPasswordLabel} newPasswordLabel={newPasswordLabel} + passwordComplexitySettings={passwordComplexitySettings} submitLabel={submitLabel} />
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 5e553b82ce..0557e13279 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 441f15484e..4146bfd340 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 a56b61d583..b27c75f97d 100644 --- a/core/vibes/soul/sections/reviews/index.tsx +++ b/core/vibes/soul/sections/reviews/index.tsx @@ -28,12 +28,16 @@ interface Props { formButtonLabel?: string; formModalTitle?: string; formSubmitLabel?: string; + formCancelLabel?: string; formRatingLabel?: string; formTitleLabel?: string; 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 }>; } @@ -52,6 +56,7 @@ export function Reviews({ formButtonLabel = 'Write a review', formModalTitle, formSubmitLabel, + formCancelLabel, formRatingLabel, formTitleLabel, formReviewLabel, @@ -69,6 +74,7 @@ export function Reviews({ >; + streamableImages: Streamable<{ + images: Array<{ src: string; alt: string }>; + pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + }>; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; }) { @@ -230,6 +241,7 @@ export function ReviewsEmptyState({

{message}

= (state: Awaited, payload: P) => S | Promise; @@ -30,12 +32,16 @@ interface Props { trigger: React.ReactNode; formModalTitle?: string; formSubmitLabel?: string; + formCancelLabel?: string; formRatingLabel?: string; formTitleLabel?: string; 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 }>; } @@ -46,6 +52,7 @@ export const ReviewForm = ({ trigger, formModalTitle = 'Write a review', formSubmitLabel = 'Submit', + formCancelLabel = 'Cancel', formRatingLabel = 'Rating', formTitleLabel = 'Title', formReviewLabel = 'Review', @@ -55,6 +62,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 +82,7 @@ export const ReviewForm = ({ author: user.name, }, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseWithZodTranslatedErrors(formData, { schema, errorTranslations }); }, onSubmit(event, { formData }) { event.preventDefault(); @@ -120,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 ( <> @@ -213,7 +222,7 @@ export const ReviewForm = ({ ))}
{formSubmitLabel}
diff --git a/core/vibes/soul/sections/reviews/schema.ts b/core/vibes/soul/sections/reviews/schema.ts index 51f422639b..75519a6c96 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 5a7ed0f12b..6e746ff97a 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 9c0a902836..26c31ae731 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 }); }, }); diff --git a/core/vibes/soul/sections/slideshow/index.tsx b/core/vibes/soul/sections/slideshow/index.tsx index 80594992dc..f98a1c5fa3 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/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 86e3be5292..94898ba888 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 f1a40ae5de..eee50458f6 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 b031df5699..0000000000 --- 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 b2cca62eda..0000000000 --- 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 0000000000..68a32cab57 --- /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 0000000000..a97980e2e2 --- /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 5759071df6..76f750de9b 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 0000000000..ffdb319902 --- /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 8f8cd50bcb..4a99a17ac5 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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ebc8273c..bc3caaa913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,27 +134,27 @@ 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 + 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) 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 @@ -6019,8 +6019,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.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} @@ -6172,28 +6172,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==} @@ -7399,10 +7399,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==} @@ -8489,9 +8485,6 @@ packages: parse5-parser-stream@7.1.2: resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -8978,7 +8971,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: @@ -10652,6 +10644,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 @@ -17496,6 +17489,7 @@ snapshots: dependencies: '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 + optional: true csstype@2.6.21: {} @@ -17509,6 +17503,7 @@ snapshots: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + optional: true data-view-buffer@1.0.2: dependencies: @@ -17678,7 +17673,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.2.6: + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -17744,25 +17739,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: {} @@ -18895,6 +18890,7 @@ snapshots: html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 + optional: true html-escaper@2.0.2: {} @@ -19162,7 +19158,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: {} @@ -19252,16 +19249,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) @@ -19689,7 +19676,7 @@ snapshots: https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.20 - parse5: 7.2.1 + parse5: 7.3.0 rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -19705,6 +19692,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true jsesc@3.1.0: {} @@ -20328,7 +20316,8 @@ snapshots: optionalDependencies: next: 15.5.10(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.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: @@ -20562,10 +20551,6 @@ snapshots: dependencies: parse5: 7.3.0 - parse5@7.2.1: - dependencies: - entities: 4.5.0 - parse5@7.3.0: dependencies: entities: 6.0.1 @@ -21354,7 +21339,8 @@ snapshots: transitivePeerDependencies: - supports-color - rrweb-cssom@0.8.0: {} + rrweb-cssom@0.8.0: + optional: true rspack-resolver@1.2.2: optionalDependencies: @@ -21408,6 +21394,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true scheduler@0.26.0: {} @@ -21918,7 +21905,8 @@ snapshots: react: 19.1.5 use-sync-external-store: 1.5.0(react@19.1.5) - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true synckit@0.11.8: dependencies: @@ -22073,6 +22061,7 @@ snapshots: tldts@6.1.86: dependencies: tldts-core: 6.1.86 + optional: true tmp@0.0.33: dependencies: @@ -22104,6 +22093,7 @@ snapshots: tough-cookie@5.1.2: dependencies: tldts: 6.1.86 + optional: true tr46@0.0.3: {} @@ -22114,6 +22104,7 @@ snapshots: tr46@5.1.1: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: {} @@ -22601,6 +22592,7 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true walker@1.0.8: dependencies: @@ -22612,7 +22604,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: @@ -22647,6 +22640,7 @@ snapshots: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + optional: true whatwg-url@5.0.0: dependencies: @@ -22788,9 +22782,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 xtend@2.1.2: dependencies: diff --git a/unlighthouse.config.ts b/unlighthouse.config.ts index e323a87b54..796466a4c9 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;