diff --git a/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx b/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx index 4b7b5cda..8cda28e2 100644 --- a/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx @@ -1,11 +1,10 @@ -import { eq } from 'drizzle-orm'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { z } from 'zod'; -import { db } from '@/db'; -import { productPrices, products } from '@/db/schema'; +import { ProductNotFoundError } from '@/lib/errors/products'; import { issueCsrfToken } from '@/lib/security/csrf'; +import { getAdminProductByIdWithPrices } from '@/lib/services/products'; import type { CurrencyCode } from '@/lib/shop/currency'; import { currencyValues } from '@/lib/shop/currency'; @@ -37,22 +36,18 @@ export default async function EditProductPage({ const parsed = paramsSchema.safeParse(rawParams); if (!parsed.success) notFound(); - const [product] = await db - .select() - .from(products) - .where(eq(products.id, parsed.data.id)) - .limit(1); + let product; + try { + product = await getAdminProductByIdWithPrices(parsed.data.id); + } catch (error) { + if (error instanceof ProductNotFoundError) { + notFound(); + } - if (!product) notFound(); + throw error; + } - const prices = await db - .select({ - currency: productPrices.currency, - price: productPrices.price, - originalPrice: productPrices.originalPrice, - }) - .from(productPrices) - .where(eq(productPrices.productId, product.id)); + const prices = product.prices; const initialPrices = prices.length ? prices @@ -98,6 +93,7 @@ export default async function EditProductPage({ stock: product.stock, sku: product.sku ?? undefined, imageUrl: product.imageUrl, + images: product.images, }} /> diff --git a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx index c2ffb546..dfa2f2c5 100644 --- a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx +++ b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx @@ -1,11 +1,13 @@ 'use client'; +import Image from 'next/image'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from '@/i18n/routing'; import { CATEGORIES, COLORS, PRODUCT_TYPES, SIZES } from '@/lib/config/catalog'; import { logError } from '@/lib/logging'; -import type { ProductAdminInput } from '@/lib/validation/shop'; +import type { AdminProductPhotoPlan } from '@/lib/validation/shop'; +import type { ProductAdminInput, ProductImage } from '@/lib/validation/shop'; const localSlugify = (input: string): string => { return input @@ -20,7 +22,10 @@ const localSlugify = (input: string): string => { type ProductFormProps = { mode: 'create' | 'edit'; productId?: string; - initialValues?: Partial & { imageUrl?: string }; + initialValues?: Partial & { + imageUrl?: string; + images?: ProductImage[]; + }; csrfToken: string; }; @@ -42,6 +47,17 @@ type UiPriceRow = { originalPrice: string; }; +export type UiPhoto = { + key: string; + source: 'existing' | 'legacy' | 'new'; + imageId?: string; + uploadId?: string; + previewUrl: string; + publicId?: string; + isPrimary: boolean; + file?: File; +}; + type SaleRuleDetails = { currency?: CurrencyCode; field?: string; @@ -125,6 +141,155 @@ function ensureUiPriceRows(fromInitial: unknown): UiPriceRow[] { }); } +function normalizeUiPhotos(photos: UiPhoto[]): UiPhoto[] { + if (photos.length === 0) return []; + + const primaryIndex = photos.findIndex(photo => photo.isPrimary); + const effectivePrimaryIndex = primaryIndex >= 0 ? primaryIndex : 0; + + return photos.map((photo, index) => ({ + ...photo, + isPrimary: index === effectivePrimaryIndex, + })); +} + +type SerializableUiPhoto = + | (UiPhoto & { source: 'existing'; imageId: string }) + | (UiPhoto & { source: 'new'; uploadId: string; file?: File }); + +function isSerializableUiPhoto(photo: UiPhoto): photo is SerializableUiPhoto { + if (photo.source === 'existing') { + return typeof photo.imageId === 'string' && photo.imageId.trim().length > 0; + } + + if (photo.source === 'new') { + return ( + typeof photo.uploadId === 'string' && photo.uploadId.trim().length > 0 + ); + } + + return false; +} + +export const LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE = + 'Legacy product photos must be migrated before adding or reordering gallery photos.'; + +class PhotoPlanSubmissionError extends Error { + constructor(message: string) { + super(message); + this.name = 'PhotoPlanSubmissionError'; + } +} + +export function getPhotoPlanSubmissionError(photos: UiPhoto[]): 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 null; +} + +export function buildPhotoPlanSubmission(photos: UiPhoto[]): { + photoPlan?: AdminProductPhotoPlan; + newPhotos: Array; +} { + const submissionError = getPhotoPlanSubmissionError(photos); + if (submissionError) { + throw new PhotoPlanSubmissionError(submissionError); + } + + const serializablePhotos = photos.filter(isSerializableUiPhoto); + + if (serializablePhotos.length === 0) { + return { photoPlan: undefined, newPhotos: [] }; + } + + const primaryIndex = serializablePhotos.findIndex(photo => photo.isPrimary); + const effectivePrimaryIndex = primaryIndex >= 0 ? primaryIndex : 0; + + const photoPlan = serializablePhotos.map((photo, index) => ({ + imageId: photo.source === 'existing' ? photo.imageId : undefined, + uploadId: photo.source === 'new' ? photo.uploadId : undefined, + isPrimary: index === effectivePrimaryIndex, + })); + + const newPhotos = serializablePhotos.filter( + ( + photo + ): photo is UiPhoto & { source: 'new'; uploadId: string; file: File } => + photo.source === 'new' && Boolean(photo.file) + ); + + return { photoPlan, newPhotos }; +} + +function getBlobPreviewUrls(photos: UiPhoto[]): Set { + return new Set( + photos + .filter( + photo => photo.source === 'new' && photo.previewUrl.startsWith('blob:') + ) + .map(photo => photo.previewUrl) + ); +} + +export function revokeSupersededPhotoPreviewUrls( + previousPhotos: UiPhoto[], + nextPhotos: UiPhoto[] +) { + const nextBlobPreviewUrls = getBlobPreviewUrls(nextPhotos); + + previousPhotos.forEach(photo => { + if (photo.source !== 'new' || !photo.previewUrl.startsWith('blob:')) { + return; + } + + if (nextBlobPreviewUrls.has(photo.previewUrl)) { + return; + } + + URL.revokeObjectURL(photo.previewUrl); + }); +} + +export function ensureUiPhotos(fromInitial: { + images?: ProductImage[]; + imageUrl?: string; +}): UiPhoto[] { + const explicitImages = Array.isArray(fromInitial.images) + ? [...fromInitial.images] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(image => ({ + key: `existing:${image.id}`, + source: 'existing' as const, + imageId: image.id, + previewUrl: image.imageUrl, + publicId: image.imagePublicId, + isPrimary: image.isPrimary, + })) + : []; + + if (explicitImages.length > 0) { + return normalizeUiPhotos(explicitImages); + } + + if (fromInitial.imageUrl) { + return [ + { + key: 'legacy-image', + source: 'legacy', + previewUrl: fromInitial.imageUrl, + isPrimary: true, + }, + ]; + } + + return []; +} + export function ProductForm({ mode, productId, @@ -150,6 +315,7 @@ export function ProductForm({ const uahOriginalErrorId = `${idBase}-uah-original-error`; const hydratedKeyRef = useRef(null); + const photosRef = useRef([]); const [title, setTitle] = useState(initialValues?.title ?? ''); const [slug, setSlug] = useState( initialValues?.slug @@ -184,8 +350,12 @@ export function ProductForm({ initialValues?.isFeatured ?? false ); - const [imageFile, setImageFile] = useState(null); - const existingImageUrl = initialValues?.imageUrl; + const [photos, setPhotos] = useState( + ensureUiPhotos({ + images: initialValues?.images, + imageUrl: initialValues?.imageUrl, + }) + ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -195,6 +365,20 @@ export function ProductForm({ Partial> >({}); + function replacePhotos( + nextOrUpdater: UiPhoto[] | ((prev: UiPhoto[]) => UiPhoto[]) + ) { + setPhotos(prev => { + const next = + typeof nextOrUpdater === 'function' + ? nextOrUpdater(prev) + : nextOrUpdater; + revokeSupersededPhotoPreviewUrls(prev, next); + photosRef.current = next; + return next; + }); + } + useEffect(() => { if (mode !== 'edit') { hydratedKeyRef.current = null; @@ -224,7 +408,6 @@ export function ProductForm({ setImageError(null); setOriginalPriceErrors({}); setIsSubmitting(false); - setImageFile(null); if (typeof initialValues.title === 'string') setTitle(initialValues.title); if (typeof initialValues.slug === 'string') @@ -243,9 +426,29 @@ export function ProductForm({ setDescription(initialValues.description ?? ''); setIsActive(initialValues.isActive ?? true); setIsFeatured(initialValues.isFeatured ?? false); + replacePhotos( + ensureUiPhotos({ + images: initialValues.images, + imageUrl: initialValues.imageUrl, + }) + ); hydratedKeyRef.current = key; }, [mode, initialValues, productId]); + useEffect(() => { + photosRef.current = photos; + }, [photos]); + + useEffect(() => { + return () => { + photosRef.current.forEach(photo => { + if (photo.source === 'new' && photo.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(photo.previewUrl); + } + }); + }; + }, []); + const slugValue = useMemo(() => { if (mode === 'edit') return slug; return localSlugify(title); @@ -284,10 +487,57 @@ export function ProductForm({ }); } - const handleImageChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] ?? null; - setImageFile(file); + const handlePhotoFilesChange = ( + event: React.ChangeEvent + ) => { + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + + const nextPhotos = files + .filter(file => file.size > 0) + .map(file => ({ + key: `new:${crypto.randomUUID()}`, + source: 'new' as const, + uploadId: crypto.randomUUID(), + previewUrl: URL.createObjectURL(file), + isPrimary: false, + file, + })); + + replacePhotos(prev => normalizeUiPhotos([...prev, ...nextPhotos])); setImageError(null); + event.target.value = ''; + }; + + const setPrimaryPhoto = (key: string) => { + replacePhotos(prev => + prev.map(photo => ({ + ...photo, + isPrimary: photo.key === key, + })) + ); + setImageError(null); + }; + + const movePhoto = (key: string, direction: -1 | 1) => { + replacePhotos(prev => { + const index = prev.findIndex(photo => photo.key === key); + if (index < 0) return prev; + + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= prev.length) return prev; + + const next = [...prev]; + const [photo] = next.splice(index, 1); + next.splice(nextIndex, 0, photo); + return normalizeUiPhotos(next); + }); + }; + + const removePhoto = (key: string) => { + replacePhotos(prev => + normalizeUiPhotos(prev.filter(photo => photo.key !== key)) + ); }; const handleSubmit = async (event: React.FormEvent) => { @@ -298,8 +548,8 @@ export function ProductForm({ setImageError(null); setOriginalPriceErrors({}); - if (mode === 'create' && !imageFile) { - setImageError('Image file is required.'); + if (photos.length === 0) { + setImageError('At least one product photo is required.'); return; } @@ -368,9 +618,36 @@ export function ProductForm({ formData.append('isActive', isActive ? 'true' : 'false'); formData.append('isFeatured', isFeatured ? 'true' : 'false'); - if (imageFile) { - formData.append('image', imageFile); + const photoSubmission = (() => { + try { + return buildPhotoPlanSubmission(photos); + } catch (photoPlanError) { + if (photoPlanError instanceof PhotoPlanSubmissionError) { + setImageError(photoPlanError.message); + return null; + } + + throw photoPlanError; + } + })(); + + if (!photoSubmission) { + return; + } + + const { photoPlan, newPhotos } = photoSubmission; + + if (photoPlan?.length) { + formData.append('photoPlan', JSON.stringify(photoPlan)); + formData.append( + 'newImageUploadIds', + JSON.stringify(newPhotos.map(photo => photo.uploadId)) + ); + newPhotos.forEach(photo => { + formData.append('newImages', photo.file); + }); } + if (!csrfToken) { setError('Security token missing. Refresh the page and retry.'); setIsSubmitting(false); @@ -397,8 +674,22 @@ export function ProductForm({ setSlugError('This slug is already used. Try changing the title.'); } - if (data.code === 'IMAGE_UPLOAD_FAILED' || data.field === 'image') { - setImageError(data.error ?? 'Failed to upload image'); + const photoErrorFields = new Set([ + 'image', + 'photos', + 'photoPlan', + 'newImages', + 'newImageUploadIds', + ]); + + if ( + (typeof data.field === 'string' && + photoErrorFields.has(data.field)) || + data.code === 'IMAGE_UPLOAD_FAILED' || + data.code === 'IMAGE_REQUIRED' + ) { + setImageError(data.error ?? 'Failed to update product photos'); + return; } if (data.code === 'SALE_ORIGINAL_REQUIRED') { @@ -416,6 +707,7 @@ export function ProductForm({ setError(data.error ?? msg); return; } + if ( response.status === 403 && (data.code === 'CSRF_MISSING' || data.code === 'CSRF_INVALID') @@ -916,28 +1208,97 @@ export function ProductForm({ /> -
+
- {existingImageUrl && !imageFile ? ( -

- Current image will be kept unless you upload a new one. -

+

+ Add one or more photos, reorder them, and mark exactly one as + primary. +

+ {photos.length > 0 ? ( +
+ {photos.map((photo, index) => ( +
+ {`Product +
+
+ Photo {index + 1} + {photo.isPrimary ? ( + + Primary + + ) : null} + + {photo.source === 'existing' + ? 'Saved' + : photo.source === 'legacy' + ? 'Legacy image' + : 'New upload'} + +
+ +
+ + + + +
+
+
+ ))} +
) : null} {imageError ? (

v !== undefined) - ), - } as any; + const product = result.product; + const commerceProduct = + result.kind === 'available' ? result.commerceProduct : null; + const availabilityState = getStorefrontAvailabilityState(commerceProduct); + const sizeGuide = getApparelSizeGuideForProduct(commerceProduct, locale); + const galleryImages = getProductGalleryImages(product); const NAV_LINK = cn( SHOP_NAV_LINK_BASE, @@ -77,40 +70,38 @@ export default async function ProductPage({

-
- {badge && badge !== 'NONE' && ( - - {badgeLabel} - - )} - - {product.name} -
+

{product.name}

- {isUnavailable ? ( -
- {t('notAvailable')} +
+ {availabilityState === 'available_to_order' + ? tProduct('availability.availableToOrder') + : availabilityState === 'out_of_stock' + ? tProduct('availability.currentlyUnavailable') + : tProduct('availability.unavailableLocaleCurrency')} +
+ + {commerceProduct === null ? ( +
+ {tProduct('availability.browseOtherProducts')}
) : (
- {formatMoney(product.price, product.currency, locale)} + {formatMoney( + commerceProduct.price, + commerceProduct.currency, + locale + )} - {product.originalPrice && ( + {commerceProduct.originalPrice && ( - {formatMoney(product.originalPrice, product.currency, locale)} + {formatMoney( + commerceProduct.originalPrice, + commerceProduct.currency, + locale + )} )}
@@ -146,11 +145,14 @@ export default async function ProductPage({ ); })()} - {!isUnavailable && ( + {commerceProduct ? (
- +
- )} + ) : null}
diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts index 353d402e..0cfeb380 100644 --- a/frontend/app/api/shop/admin/products/[id]/route.ts +++ b/frontend/app/api/shop/admin/products/[id]/route.ts @@ -5,7 +5,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/db'; -import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; +import { + parseAdminProductForm, + parseAdminProductPhotosForm, +} from '@/lib/admin/parseAdminProductForm'; import { AdminApiDisabledError, AdminForbiddenError, @@ -422,6 +425,28 @@ export async function PATCH( { status: 400 } ); } + const parsedPhotos = parseAdminProductPhotosForm(formData, { + mode: 'update', + }); + if (!parsedPhotos.ok) { + logWarn('admin_product_update_invalid_photos', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + productId: productIdForLog, + issuesCount: getIssuesCount(parsedPhotos.error), + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product photos', + code: 'INVALID_PAYLOAD', + field: 'photos', + details: parsedPhotos.error.format(), + }, + { status: 400 } + ); + } const saleViolation = findSaleRuleViolation(parsed.data); if (saleViolation) { const message = @@ -450,14 +475,10 @@ export async function PATCH( } try { - const imageFile = formData.get('image'); - const updated = await updateProduct(productIdForLog, { ...(parsed.data as UpdateProductInput), - image: - imageFile instanceof File && imageFile.size > 0 - ? imageFile - : undefined, + imagePlan: parsedPhotos.data.imagePlan, + images: parsedPhotos.data.images, }); try { @@ -597,7 +618,7 @@ export async function PATCH( { error: 'Failed to upload product image', code: 'IMAGE_UPLOAD_FAILED', - field: 'image', + field: 'photos', }, { status: 502 } ); diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 80744775..e4c8c2fa 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -2,14 +2,16 @@ import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; -import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; +import { + parseAdminProductForm, + parseAdminProductPhotosForm, +} from '@/lib/admin/parseAdminProductForm'; import { AdminApiDisabledError, AdminForbiddenError, AdminUnauthorizedError, requireAdminApi, } from '@/lib/auth/admin'; -import { destroyProductImage } from '@/lib/cloudinary'; import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; @@ -160,25 +162,6 @@ export async function POST(request: NextRequest) { return csrfRes; } - const imageFile = formData.get('image'); - if (!(imageFile instanceof File) || imageFile.size === 0) { - logWarn('admin_product_create_image_required', { - ...baseMeta, - code: 'IMAGE_REQUIRED', - slug: slugForLog, - durationMs: Date.now() - startedAtMs, - }); - - return noStoreJson( - { - error: 'Image file is required', - code: 'IMAGE_REQUIRED', - field: 'image', - }, - { status: 400 } - ); - } - const saleViolationFromForm = getSaleViolationFromFormData(formData); if (isInvalidPricesJsonError(saleViolationFromForm)) { logWarn('admin_product_create_invalid_prices_json', { @@ -225,6 +208,9 @@ export async function POST(request: NextRequest) { } const parsed = parseAdminProductForm(formData, { mode: 'create' }); + const parsedPhotos = parseAdminProductPhotosForm(formData, { + mode: 'create', + }); if (!parsed.ok) { const issuesCount = @@ -248,6 +234,48 @@ export async function POST(request: NextRequest) { ); } + if (!parsedPhotos.ok) { + const issuesCount = + ((parsedPhotos.error as any)?.issues?.length as number | undefined) ?? + 0; + + logWarn('admin_product_create_invalid_photos', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + slug: slugForLog, + issuesCount, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product photos', + code: 'INVALID_PAYLOAD', + field: 'photos', + details: parsedPhotos.error.format(), + }, + { status: 400 } + ); + } + + if (!parsedPhotos.data.imagePlan?.length) { + logWarn('admin_product_create_image_required', { + ...baseMeta, + code: 'IMAGE_REQUIRED', + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'At least one product photo is required', + code: 'IMAGE_REQUIRED', + field: 'photos', + }, + { status: 400 } + ); + } + const saleViolation = findSaleRuleViolation(parsed.data as any); if (saleViolation) { const message = @@ -284,7 +312,8 @@ export async function POST(request: NextRequest) { try { const inserted = await createProduct({ ...parsed.data, - image: imageFile, + imagePlan: parsedPhotos.data.imagePlan, + images: parsedPhotos.data.images, }); try { @@ -324,10 +353,8 @@ export async function POST(request: NextRequest) { durationMs: Date.now() - startedAtMs, }); - let rollbackDeleted = false; try { await deleteProduct(inserted.id); - rollbackDeleted = true; } catch (rollbackError) { logError( 'admin_product_create_audit_rollback_failed', @@ -342,25 +369,6 @@ export async function POST(request: NextRequest) { ); } - try { - if (rollbackDeleted && inserted.imagePublicId) { - await destroyProductImage(inserted.imagePublicId); - } - } catch (imgError) { - logError( - 'admin_product_create_audit_rollback_image_failed', - imgError, - { - ...baseMeta, - code: 'AUDIT_ROLLBACK_IMAGE_FAILED', - productId: inserted.id, - slug: inserted.slug, - imagePublicId: inserted.imagePublicId ?? null, - durationMs: Date.now() - startedAtMs, - } - ); - } - throw auditError; } @@ -439,7 +447,7 @@ export async function POST(request: NextRequest) { { error: 'Failed to upload product image', code: 'IMAGE_UPLOAD_FAILED', - field: 'image', + field: 'photos', }, { status: 502 } ); diff --git a/frontend/components/shop/AddToCartButton.tsx b/frontend/components/shop/AddToCartButton.tsx index 3e73db31..e73951ca 100644 --- a/frontend/components/shop/AddToCartButton.tsx +++ b/frontend/components/shop/AddToCartButton.tsx @@ -4,7 +4,13 @@ import { Check, Minus, Plus } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useId, useState } from 'react'; -import type { ShopProduct } from '@/lib/shop/data'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import type { ApparelSizeGuide } from '@/lib/shop/size-guide'; import { SHOP_CHIP_HOVER, SHOP_CHIP_INTERACTIVE, @@ -20,14 +26,16 @@ import { shopCtaGradient, } from '@/lib/shop/ui-classes'; import { cn } from '@/lib/utils'; +import type { ShopProduct } from '@/lib/validation/shop'; import { useCart } from './CartProvider'; interface AddToCartButtonProps { product: ShopProduct; + sizeGuide?: ApparelSizeGuide | null; } -export function AddToCartButton({ product }: AddToCartButtonProps) { +export function AddToCartButton({ product, sizeGuide }: AddToCartButtonProps) { const { addToCart } = useCart(); const t = useTranslations('shop.product'); const tCartActions = useTranslations('shop.cart.actions'); @@ -155,6 +163,83 @@ export function AddToCartButton({ product }: AddToCartButtonProps) { {t('size')} + {sizeGuide ? ( + + + + {sizeGuide.label} + + +
+

+ {sizeGuide.title} +

+

+ {sizeGuide.intro} +

+

+ {sizeGuide.measurementNote} +

+
+ +
    + {sizeGuide.fitNotes.map(note => ( +
  • {note}
  • + ))} +
+ +
+

+ {sizeGuide.chart.caption} +

+ +
+ + + + + + + + + + + {sizeGuide.chart.rows.map(row => ( + + + + + + ))} + +
+ {sizeGuide.chart.caption} +
+ {sizeGuide.chart.columns.size} + + {sizeGuide.chart.columns.chestWidth} + + {sizeGuide.chart.columns.bodyLength} +
+ {row.size} + + {row.chestWidthCm} {sizeGuide.chart.unit} + + {row.bodyLengthCm} {sizeGuide.chart.unit} +
+
+
+
+
+
+ ) : null} +
{!product.inStock ? ( - t('soldOut') + t('availability.currentlyUnavailable') ) : added ? ( <>