diff --git a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx index b01f4721..eb18e41e 100644 --- a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx +++ b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx @@ -1,6 +1,7 @@ 'use client'; import Image from 'next/image'; +import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from '@/i18n/routing'; @@ -65,8 +66,6 @@ type SaleRuleDetails = { rule?: 'required' | 'greater_than_price'; }; -const SALE_REQUIRED_MSG = 'Original price is required for SALE.'; -const SALE_GREATER_MSG = 'Original price must be greater than price for SALE.'; const CARD_CLASS = 'rounded-xl border border-border bg-background/80 p-5 shadow-sm'; const LABEL_CLASS = 'block text-sm font-medium text-foreground'; @@ -81,6 +80,16 @@ const SECONDARY_BUTTON_CLASS = const PRIMARY_BUTTON_CLASS = 'inline-flex h-10 w-full items-center justify-center rounded-md bg-foreground px-4 text-sm font-semibold text-background transition-colors hover:bg-foreground/90 disabled:opacity-60'; +class InvalidMoneyValueError extends Error { + rawValue: string; + + constructor(rawValue: string) { + super('INVALID_MONEY_VALUE'); + this.name = 'InvalidMoneyValueError'; + this.rawValue = rawValue; + } +} + function formatMinorToMajor(value: number): string { if (!Number.isFinite(value)) return ''; const abs = Math.abs(Math.trunc(value)); @@ -93,13 +102,13 @@ function formatMinorToMajor(value: number): string { function parseMajorToMinor(value: string): number { const s = value.trim().replace(',', '.'); if (!/^\d+(\.\d{1,2})?$/.test(s)) { - throw new Error(`Invalid money value: "${value}"`); + throw new InvalidMoneyValueError(value); } const [whole, frac = ''] = s.split('.'); const frac2 = (frac + '00').slice(0, 2); const minor = Number(whole) * 100 + Number(frac2); if (!Number.isSafeInteger(minor) || minor < 0) { - throw new Error(`Invalid money value: "${value}"`); + throw new InvalidMoneyValueError(value); } return minor; } @@ -185,8 +194,9 @@ function isSerializableUiPhoto(photo: UiPhoto): photo is SerializableUiPhoto { return false; } -export const LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE = - 'Legacy product photos must be migrated before adding or reordering gallery photos.'; +type ProductFormErrorMessages = { + legacyPhotoMigrationRequired: string; +}; class PhotoPlanSubmissionError extends Error { constructor(message: string) { @@ -195,22 +205,28 @@ class PhotoPlanSubmissionError extends Error { } } -export function getPhotoPlanSubmissionError(photos: UiPhoto[]): string | null { +export function getPhotoPlanSubmissionError( + photos: UiPhoto[], + messages: ProductFormErrorMessages +): string | null { const hasLegacyPhotos = photos.some(photo => photo.source === 'legacy'); const hasNonLegacyPhotos = photos.some(photo => photo.source !== 'legacy'); if (hasLegacyPhotos && hasNonLegacyPhotos) { - return LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE; + return messages.legacyPhotoMigrationRequired; } return null; } -export function buildPhotoPlanSubmission(photos: UiPhoto[]): { +export function buildPhotoPlanSubmission( + photos: UiPhoto[], + messages: ProductFormErrorMessages +): { photoPlan?: AdminProductPhotoPlan; newPhotos: Array; } { - const submissionError = getPhotoPlanSubmissionError(photos); + const submissionError = getPhotoPlanSubmissionError(photos, messages); if (submissionError) { throw new PhotoPlanSubmissionError(submissionError); } @@ -311,6 +327,7 @@ export function ProductForm({ csrfToken, }: ProductFormProps) { const router = useRouter(); + const t = useTranslations('shop.admin.products.form'); const idBase = useMemo(() => { const pid = @@ -563,7 +580,7 @@ export function ProductForm({ setOriginalPriceErrors({}); if (photos.length === 0) { - setImageError('At least one product photo is required.'); + setImageError(t('errors.photoRequired')); return; } @@ -580,21 +597,30 @@ export function ProductForm({ p => p.price.length > 0 || p.originalPrice.length > 0 ); + if (effectivePrices.length === 0) { + setError(t('errors.atLeastOnePrice')); + return; + } + + const storefrontUahPrice = normalizedPrices.find( + price => price.currency === 'UAH' + ); + if (!storefrontUahPrice?.price.length) { + setError(t('errors.uahRequired')); + return; + } + for (const p of effectivePrices) { if (!p.price.length && p.originalPrice.length) { setError( - `${p.currency}: price is required when original price is set.` + t('errors.priceRequiredWhenOriginalSet', { + currency: p.currency, + }) ); return; } } - const usd = effectivePrices.find(p => p.currency === 'USD'); - if (!usd || !usd.price.length) { - setError('USD price is required.'); - return; - } - let minorPrices: Array<{ currency: CurrencyCode; priceMinor: number; @@ -610,7 +636,11 @@ export function ProductForm({ : null, })); } catch (e) { - setError(e instanceof Error ? e.message : 'Invalid price value.'); + if (e instanceof InvalidMoneyValueError) { + setError(t('errors.invalidMoneyValue', { value: e.rawValue })); + return; + } + setError(t('errors.invalidPriceValue')); return; } @@ -634,7 +664,11 @@ export function ProductForm({ const photoSubmission = (() => { try { - return buildPhotoPlanSubmission(photos); + return buildPhotoPlanSubmission(photos, { + legacyPhotoMigrationRequired: t( + 'errors.legacyPhotoMigrationRequired' + ), + }); } catch (photoPlanError) { if (photoPlanError instanceof PhotoPlanSubmissionError) { setImageError(photoPlanError.message); @@ -663,7 +697,7 @@ export function ProductForm({ } if (!csrfToken) { - setError('Security token missing. Refresh the page and retry.'); + setError(t('errors.securityMissing')); setIsSubmitting(false); return; } @@ -685,7 +719,7 @@ export function ProductForm({ if (!response.ok) { if (data.code === 'SLUG_CONFLICT' || data.field === 'slug') { - setSlugError('This slug is already used. Try changing the title.'); + setSlugError(t('errors.slugConflict')); } const photoErrorFields = new Set([ @@ -702,7 +736,7 @@ export function ProductForm({ data.code === 'IMAGE_UPLOAD_FAILED' || data.code === 'IMAGE_REQUIRED' ) { - setImageError(data.error ?? 'Failed to update product photos'); + setImageError(data.error ?? t('errors.photoUpdateFailed')); return; } @@ -711,8 +745,8 @@ export function ProductForm({ const currency = details?.currency; const msg = details?.rule === 'greater_than_price' - ? SALE_GREATER_MSG - : SALE_REQUIRED_MSG; + ? t('errors.saleOriginalGreater') + : t('errors.saleOriginalRequired'); if (currency === 'USD' || currency === 'UAH') { setOriginalPriceErrors(prev => ({ ...prev, [currency]: msg })); @@ -726,13 +760,15 @@ export function ProductForm({ response.status === 403 && (data.code === 'CSRF_MISSING' || data.code === 'CSRF_INVALID') ) { - setError('Security token expired. Refresh the page and retry.'); + setError(t('errors.securityExpired')); return; } setError( data.error ?? - `Failed to ${mode === 'create' ? 'create' : 'update'} product` + (mode === 'create' + ? t('errors.createFailed') + : t('errors.updateFailed')) ); return; } @@ -747,9 +783,9 @@ export function ProductForm({ }); setError( - `Unexpected error while ${ - mode === 'create' ? 'creating' : 'updating' - } product.` + mode === 'create' + ? t('errors.unexpectedCreate') + : t('errors.unexpectedUpdate') ); } finally { setIsSubmitting(false); @@ -763,7 +799,7 @@ export function ProductForm({ return (

- {mode === 'create' ? 'Create new product' : 'Edit product'} + {mode === 'create' ? t('headings.create') : t('headings.edit')}

{error ? (
- Auto-generated from title + {t('fields.slugHelp')}
- Prices + {t('pricing.legend')} +

+ {t('pricing.helper')} +

+
- USD (required) + {t('pricing.uahLegend')}
-
-
@@ -898,55 +937,55 @@ export function ProductForm({
- UAH (optional) + {t('pricing.usdLegend')}
-
-
@@ -954,19 +993,19 @@ export function ProductForm({

- Checkout currency is server-selected by locale. Prices must exist in{' '} - product_prices for that currency, - or checkout fails. + {t('pricing.policyPrefix')}{' '} + product_prices{' '} + {t('pricing.policySuffix')}

setType(event.target.value)} > - + {PRODUCT_TYPES.map(productType => (
-
+