From e0f2377f3350a1ed2c691af2d250d8f6fb536ad0 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 18 Jul 2026 11:04:42 +0200 Subject: [PATCH 1/3] fix big files --- .../products/product-form-image-helpers.ts | 159 +++ .../seller/products/product-form-payload.ts | 97 ++ .../products/product-form-photo-galleries.tsx | 102 ++ .../product-form-translation-helpers.ts | 143 +++ .../seller/products/product-form-types.ts | 83 ++ .../seller/products/product-form-upload.ts | 57 + .../seller/products/product-form-view.tsx | 117 ++ app/[locale]/seller/products/product-form.tsx | 1114 +---------------- .../products/use-product-form-fields.ts | 167 +++ .../products/use-product-form-photos.ts | 82 ++ .../products/use-product-form-submission.ts | 102 ++ .../seller/products/use-product-form.ts | 155 +++ app/api/cart/items/[itemId]/route.ts | 5 + app/api/cart/items/route.ts | 5 + composition-root/container.ts | 21 + modules/cart/application/add-item-to-cart.ts | 24 + modules/cart/application/update-cart-item.ts | 98 +- .../customer-customization-create-port.ts | 21 + .../components/add-to-cart-button-view.tsx | 175 +++ .../components/add-to-cart-button.tsx | 738 +---------- .../components/add-to-cart-types.ts | 42 + .../cart/presentation/components/cart-api.ts | 62 + .../components/cart-item-matching.ts | 72 ++ .../components/use-add-to-cart-button.ts | 173 +++ .../use-authenticated-cart-actions.ts | 93 ++ .../components/use-authenticated-cart.ts | 66 + .../components/use-guest-cart-actions.ts | 65 + .../cart/presentation/schemas/cart-schemas.ts | 29 + .../create-customer-customization.ts | 3 +- .../domain/customization-repository.ts | 2 +- .../cart-customer-customization-creator.ts | 23 + .../prisma-customization-repository.ts | 9 +- .../seller/products/product-form.test.tsx | 44 + .../cart/add-to-cart-button.test.tsx | 153 ++- .../cart/application/add-item-to-cart.test.ts | 68 + .../cart/application/update-cart-item.test.ts | 72 ++ 36 files changed, 2549 insertions(+), 1892 deletions(-) create mode 100644 app/[locale]/seller/products/product-form-image-helpers.ts create mode 100644 app/[locale]/seller/products/product-form-payload.ts create mode 100644 app/[locale]/seller/products/product-form-photo-galleries.tsx create mode 100644 app/[locale]/seller/products/product-form-translation-helpers.ts create mode 100644 app/[locale]/seller/products/product-form-types.ts create mode 100644 app/[locale]/seller/products/product-form-upload.ts create mode 100644 app/[locale]/seller/products/product-form-view.tsx create mode 100644 app/[locale]/seller/products/use-product-form-fields.ts create mode 100644 app/[locale]/seller/products/use-product-form-photos.ts create mode 100644 app/[locale]/seller/products/use-product-form-submission.ts create mode 100644 app/[locale]/seller/products/use-product-form.ts create mode 100644 modules/cart/domain/customer-customization-create-port.ts create mode 100644 modules/cart/presentation/components/add-to-cart-button-view.tsx create mode 100644 modules/cart/presentation/components/add-to-cart-types.ts create mode 100644 modules/cart/presentation/components/cart-api.ts create mode 100644 modules/cart/presentation/components/cart-item-matching.ts create mode 100644 modules/cart/presentation/components/use-add-to-cart-button.ts create mode 100644 modules/cart/presentation/components/use-authenticated-cart-actions.ts create mode 100644 modules/cart/presentation/components/use-authenticated-cart.ts create mode 100644 modules/cart/presentation/components/use-guest-cart-actions.ts create mode 100644 modules/customizations/infrastructure/cart-customer-customization-creator.ts diff --git a/app/[locale]/seller/products/product-form-image-helpers.ts b/app/[locale]/seller/products/product-form-image-helpers.ts new file mode 100644 index 00000000..79b4c06e --- /dev/null +++ b/app/[locale]/seller/products/product-form-image-helpers.ts @@ -0,0 +1,159 @@ +import { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose'; +import type { ProductFormLabels } from '@/modules/products/presentation/product-form-labels'; +import type { ProductPhotoDraft } from './product-photo-gallery'; +import type { + ProductFormImageSeed, + ProductFormImageSeeds, + ProductPhotoBucket, + ProductPhotoBucketsState, +} from './product-form-types'; + +function buildDefaultPhotoName(labels: ProductFormLabels, index: number) { + return `${labels.gallery.defaultPhotoName} ${index + 1}`; +} + +export function normalizePhotoName(value: string, fallback: string) { + return value.trim() || fallback; +} + +export function purposeForBucket( + bucket: ProductPhotoBucket, +): ProductImagePurpose { + if (bucket === 'cover') return ProductImagePurpose.COVER; + if (bucket === 'customizableBase') + return ProductImagePurpose.CUSTOMIZABLE_BASE; + return ProductImagePurpose.SHOWCASE; +} + +function bucketForPurpose( + purpose: ProductFormImageSeed['purpose'], +): ProductPhotoBucket { + if (purpose === ProductImagePurpose.COVER) return 'cover'; + if (purpose === ProductImagePurpose.CUSTOMIZABLE_BASE) + return 'customizableBase'; + return 'showcase'; +} + +function createPhotoDraft( + seed: ProductFormImageSeed, + fallbackName: string, + purpose: ProductImagePurpose, +): ProductPhotoDraft { + return { + id: seed.id ?? createPhotoId(), + url: seed.url, + alt: normalizePhotoName(seed.alt ?? '', fallbackName), + size: null, + purpose: seed.purpose ?? purpose, + mimeType: seed.mimeType ?? 'image/jpeg', + posterUrl: seed.posterUrl ?? null, + }; +} + +function createEmptyBuckets(): ProductPhotoBucketsState { + return { cover: null, showcase: [], customizableBase: [] }; +} + +export function normalizeInitialImages( + labels: ProductFormLabels, + images: ProductFormImageSeeds, +): ProductPhotoBucketsState { + const buckets = createEmptyBuckets(); + const entries = Array.isArray(images) + ? images.map((seed) => ({ seed, bucket: bucketForPurpose(seed.purpose) })) + : [ + ...(images.cover + ? [{ seed: images.cover, bucket: 'cover' as const }] + : []), + ...images.showcase.map((seed) => ({ + seed, + bucket: 'showcase' as const, + })), + ...images.customizableBase.map((seed) => ({ + seed, + bucket: 'customizableBase' as const, + })), + ]; + const indexes = { cover: 0, showcase: 0, customizableBase: 0 }; + + for (const { seed, bucket } of entries) { + const index = indexes[bucket]++; + const draft = createPhotoDraft( + seed, + buildDefaultPhotoName(labels, index), + purposeForBucket(bucket), + ); + + if (bucket === 'cover') buckets.cover = draft; + else buckets[bucket].push(draft); + } + + return buckets; +} + +export function moveItem( + items: T[], + itemId: string, + direction: -1 | 1, +) { + const currentIndex = items.findIndex((item) => item.id === itemId); + const nextIndex = currentIndex + direction; + if (currentIndex === -1 || nextIndex < 0 || nextIndex >= items.length) { + return items; + } + + const next = [...items]; + const [item] = next.splice(currentIndex, 1); + next.splice(nextIndex, 0, item); + return next; +} + +export function updateBucketPhoto( + state: ProductPhotoBucketsState, + bucket: ProductPhotoBucket, + photoId: string, + patch: Partial, +): ProductPhotoBucketsState { + if (bucket === 'cover') { + if (!state.cover || state.cover.id !== photoId) return state; + return { ...state, cover: { ...state.cover, ...patch } }; + } + + return { + ...state, + [bucket]: state[bucket].map((photo) => + photo.id === photoId ? { ...photo, ...patch } : photo, + ), + }; +} + +export function removeBucketPhoto( + state: ProductPhotoBucketsState, + bucket: ProductPhotoBucket, + photoId: string, +): ProductPhotoBucketsState { + if (bucket === 'cover') { + if (!state.cover || state.cover.id !== photoId) return state; + return { ...state, cover: null }; + } + + return { + ...state, + [bucket]: state[bucket].filter((photo) => photo.id !== photoId), + }; +} + +export function photoIdsFor(images: ProductPhotoBucketsState) { + return new Set(images.customizableBase.map((photo) => photo.id)); +} + +export function defaultPhotoName(labels: ProductFormLabels, index: number) { + return buildDefaultPhotoName(labels, index); +} + +function createPhotoId() { + return ( + globalThis.crypto?.randomUUID?.() ?? + `photo-${Date.now()}-${crypto.randomUUID()}` + ); +} diff --git a/app/[locale]/seller/products/product-form-payload.ts b/app/[locale]/seller/products/product-form-payload.ts new file mode 100644 index 00000000..cdcd56e0 --- /dev/null +++ b/app/[locale]/seller/products/product-form-payload.ts @@ -0,0 +1,97 @@ +import type { ZodError } from 'zod'; +import { productFormSchema } from '@/modules/products/presentation/schemas/product-form-schema'; +import { photoIdsFor } from './product-form-image-helpers'; +import { cleanPhotoLabels } from './product-form-translation-helpers'; +import type { + FormErrors, + FormState, + SupportedLocale, +} from './product-form-types'; + +export function buildPayload(locale: SupportedLocale, form: FormState) { + const current = form.translations[locale]; + const hasCustomizableBase = form.images.customizableBase.length > 0; + const effectiveMode = + hasCustomizableBase && form.customizationConfig?.mode === 'description' + ? 'text_photo' + : form.customizationConfig?.mode; + const customizationConfig = form.customizationConfig + ? { ...form.customizationConfig, mode: effectiveMode } + : undefined; + const photoIds = photoIdsFor(form.images); + const translations = Object.values(form.translations) + .map((translation) => { + const name = translation.name.trim(); + const description = translation.description.trim(); + const designChangeDescription = + translation.designChangeDescription?.trim() ?? ''; + return { + locale: translation.locale, + name, + description: description || undefined, + tags: [...translation.tags], + sizes: [...translation.sizes], + designChangeDescription: designChangeDescription || null, + photoLabels: cleanPhotoLabels(translation.photoLabels, photoIds), + }; + }) + .filter( + (translation) => + translation.name.length > 0 || + translation.description !== undefined || + translation.tags.length > 0 || + translation.sizes.length > 0 || + translation.designChangeDescription !== null || + Object.keys(translation.photoLabels).length > 0, + ); + const images = [ + ...(form.images.cover ? [{ ...form.images.cover, position: 0 }] : []), + ...form.images.showcase.map((image, index) => ({ + ...image, + position: index, + })), + ...form.images.customizableBase.map((image, index) => ({ + ...image, + position: index, + })), + ].map((image) => ({ + id: image.id, + url: image.url, + alt: image.alt.trim(), + position: image.position, + purpose: image.purpose, + mimeType: image.mimeType, + posterUrl: image.posterUrl, + })); + const designChangeDescription = current.designChangeDescription?.trim() ?? ''; + const payload = { + locale, + name: current.name.trim(), + description: current.description.trim() || undefined, + price: form.price, + translation: { + tags: [...current.tags], + sizes: [...current.sizes], + designChangeDescription: designChangeDescription || null, + photoLabels: cleanPhotoLabels(current.photoLabels, photoIds), + }, + translations, + customizationConfig, + images, + }; + const result = productFormSchema.safeParse(payload); + return result.success + ? { success: true as const, payload: result.data } + : { success: false as const, error: result.error, payload }; +} + +export function mapErrors(error: ZodError): FormErrors { + const errors: FormErrors = {}; + for (const issue of error.issues) { + const path = issue.path[0] as keyof FormErrors | undefined; + if (path !== undefined && !Object.hasOwn(errors, path)) { + errors[path] = issue.message; + } + } + return errors; +} diff --git a/app/[locale]/seller/products/product-form-photo-galleries.tsx b/app/[locale]/seller/products/product-form-photo-galleries.tsx new file mode 100644 index 00000000..0d098e27 --- /dev/null +++ b/app/[locale]/seller/products/product-form-photo-galleries.tsx @@ -0,0 +1,102 @@ +import { ProductPhotoBucketGallery } from './product-photo-gallery'; +import type { ProductFormLabels } from '@/modules/products/presentation/product-form-labels'; +import type { ProductFormController } from './use-product-form'; +import styles from './product-form.module.css'; + +interface ProductFormPhotoGalleriesProps { + controller: ProductFormController; + labels: ProductFormLabels['gallery']; +} + +export function ProductFormPhotoGalleries({ + controller, + labels, +}: ProductFormPhotoGalleriesProps) { + const { form } = controller; + + return ( + <> + {controller.photoError ? ( +

+ {controller.photoError} +

+ ) : null} +
+ controller.handleUpload('cover', files)} + onPhotoLabelChange={(photoId, alt) => + controller.updatePhoto('cover', photoId, { alt }) + } + onSelectPhoto={controller.selectPhoto} + onRemovePhoto={(photoId) => controller.removePhoto('cover', photoId)} + uploading={controller.uploading} + error={null} + /> + + controller.handleUpload('showcase', files) + } + onPhotoLabelChange={(photoId, alt) => + controller.updatePhoto('showcase', photoId, { alt }) + } + onSelectPhoto={controller.selectPhoto} + onRemovePhoto={(photoId) => + controller.removePhoto('showcase', photoId) + } + onMovePhotoUp={(photoId) => + controller.movePhoto('showcase', photoId, -1) + } + onMovePhotoDown={(photoId) => + controller.movePhoto('showcase', photoId, 1) + } + onPosterUrlChange={(photoId, posterUrl) => + controller.updatePhoto('showcase', photoId, { + posterUrl: posterUrl.trim() || null, + }) + } + uploading={controller.uploading} + error={null} + /> + + controller.handleUpload('customizableBase', files) + } + onPhotoLabelChange={controller.updatePhotoLabel} + onSelectPhoto={controller.selectPhoto} + onRemovePhoto={(photoId) => + controller.removePhoto('customizableBase', photoId) + } + onMovePhotoUp={(photoId) => + controller.movePhoto('customizableBase', photoId, -1) + } + onMovePhotoDown={(photoId) => + controller.movePhoto('customizableBase', photoId, 1) + } + uploading={controller.uploading} + error={null} + /> +
+ + ); +} diff --git a/app/[locale]/seller/products/product-form-translation-helpers.ts b/app/[locale]/seller/products/product-form-translation-helpers.ts new file mode 100644 index 00000000..cdd65219 --- /dev/null +++ b/app/[locale]/seller/products/product-form-translation-helpers.ts @@ -0,0 +1,143 @@ +import type { ProductTranslationInput } from '@/modules/products/presentation/schemas/product-form-schema'; +import type { + FormState, + LocaleTranslationState, + ProductFormProps, + SupportedLocale, + TranslationMap, +} from './product-form-types'; + +export function normalizeLocale(value: string): SupportedLocale { + return value === 'cat' ? 'cat' : 'es'; +} + +function createTranslationDraft( + locale: SupportedLocale, +): LocaleTranslationState { + return { + locale, + name: '', + description: '', + tags: [], + sizes: [], + designChangeDescription: null, + photoLabels: {}, + }; +} + +export function normalizeTranslationDraft( + locale: SupportedLocale, + draft?: Partial, +): LocaleTranslationState { + return { + ...createTranslationDraft(locale), + ...draft, + locale, + tags: [...(draft?.tags ?? [])], + sizes: [...(draft?.sizes ?? [])], + designChangeDescription: draft?.designChangeDescription ?? null, + photoLabels: draft?.photoLabels ? { ...draft.photoLabels } : {}, + }; +} + +export function cleanPhotoLabels( + labels: Record | undefined, + photoIds: ReadonlySet, +) { + return Object.fromEntries( + Object.entries(labels ?? {}).filter(([photoId]) => photoIds.has(photoId)), + ); +} + +export function addPhotoLabels( + translations: TranslationMap, + photoIds: readonly string[], +): TranslationMap { + if (photoIds.length === 0) return translations; + + return Object.fromEntries( + Object.entries(translations).map(([locale, translation]) => [ + locale, + { + ...translation, + photoLabels: { + ...translation.photoLabels, + ...Object.fromEntries(photoIds.map((photoId) => [photoId, ''])), + }, + }, + ]), + ) as TranslationMap; +} + +export function removePhotoLabels( + translations: TranslationMap, + photoId: string, +): TranslationMap { + return Object.fromEntries( + Object.entries(translations).map(([locale, translation]) => { + const photoLabels = { ...translation.photoLabels }; + delete photoLabels[photoId]; + return [locale, { ...translation, photoLabels }]; + }), + ) as TranslationMap; +} + +function hasInactiveTranslationContent( + translation: LocaleTranslationState, +): boolean { + return ( + translation.description.trim().length > 0 || + translation.tags.length > 0 || + translation.sizes.length > 0 || + (translation.designChangeDescription?.trim().length ?? 0) > 0 || + Object.keys(translation.photoLabels ?? {}).length > 0 + ); +} + +export function findTranslationWithMissingName( + form: FormState, +): SupportedLocale | null { + const active = form.translations[form.activeLocale]; + if (active.name.trim().length === 0) return form.activeLocale; + + return ( + Object.values(form.translations).find( + (translation) => + translation.locale !== form.activeLocale && + translation.name.trim().length === 0 && + hasInactiveTranslationContent(translation), + )?.locale ?? null + ); +} + +export function buildTranslationMap( + locale: SupportedLocale, + initialValues: ProductFormProps['initialValues'], +): TranslationMap { + const translations: TranslationMap = { + es: createTranslationDraft('es'), + cat: createTranslationDraft('cat'), + }; + + if (initialValues.translations?.length) { + for (const draft of initialValues.translations) { + translations[draft.locale] = normalizeTranslationDraft( + draft.locale, + draft, + ); + } + return translations; + } + + const translation: ProductTranslationInput | undefined = + initialValues.translation; + translations[locale] = normalizeTranslationDraft(locale, { + name: initialValues.name ?? '', + description: initialValues.description ?? '', + tags: translation?.tags ?? [], + sizes: translation?.sizes ?? [], + designChangeDescription: translation?.designChangeDescription ?? null, + photoLabels: translation?.photoLabels ?? {}, + }); + return translations; +} diff --git a/app/[locale]/seller/products/product-form-types.ts b/app/[locale]/seller/products/product-form-types.ts new file mode 100644 index 00000000..ae0af1dc --- /dev/null +++ b/app/[locale]/seller/products/product-form-types.ts @@ -0,0 +1,83 @@ +import type { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose'; +import type { + ProductCustomizationConfigInput, + ProductTranslationInput, +} from '@/modules/products/presentation/schemas/product-form-schema'; +import type { ProductFormLabels } from '@/modules/products/presentation/product-form-labels'; +import type { ProductLocale } from '@/modules/products/presentation/components/product-locale-tabs'; +import type { ProductTranslationDraft } from '@/modules/products/presentation/components/product-translation-section'; +import type { ProductPhotoDraft } from './product-photo-gallery'; + +export type ProductFormMode = 'create' | 'edit'; +export type SupportedLocale = ProductLocale; + +export interface LocaleTranslationState extends ProductTranslationDraft { + locale: SupportedLocale; +} + +export type TranslationMap = Record; + +export interface CategoryOption { + id: string; + name: string; +} + +export interface ProductFormImageSeed { + id?: string; + url: string; + alt: string | null; + purpose?: ProductImagePurpose; + mimeType?: string; + posterUrl?: string | null; +} + +interface ProductFormImageBucketsSeed { + cover: ProductFormImageSeed | null; + showcase: ProductFormImageSeed[]; + customizableBase: ProductFormImageSeed[]; +} + +export type ProductFormImageSeeds = + ProductFormImageSeed[] | ProductFormImageBucketsSeed; + +export interface ProductFormProps { + locale: string; + mode: ProductFormMode; + productId?: string; + initialValues: { + price: number; + name?: string; + description?: string; + translation?: ProductTranslationInput; + translations?: LocaleTranslationState[]; + customizationConfig: ProductCustomizationConfigInput; + images: ProductFormImageSeeds; + }; + labels: ProductFormLabels; + categories?: CategoryOption[]; +} + +export interface ProductPhotoBucketsState { + cover: ProductPhotoDraft | null; + showcase: ProductPhotoDraft[]; + customizableBase: ProductPhotoDraft[]; +} + +export type ProductPhotoBucket = keyof ProductPhotoBucketsState; + +export interface FormState { + price: string; + activeLocale: SupportedLocale; + translations: TranslationMap; + customizationConfig: ProductCustomizationConfigInput; + images: ProductPhotoBucketsState; + selectedPhotoId: string | null; +} + +export interface FormErrors { + name?: string; + description?: string; + price?: string; + images?: string; + customizationConfig?: string; +} diff --git a/app/[locale]/seller/products/product-form-upload.ts b/app/[locale]/seller/products/product-form-upload.ts new file mode 100644 index 00000000..659f19ea --- /dev/null +++ b/app/[locale]/seller/products/product-form-upload.ts @@ -0,0 +1,57 @@ +import { UploadType } from '@/modules/uploads/domain/value-objects/upload-type'; +import { toAbsoluteUrl } from '@/shared/presentation/lib/to-absolute-url'; +import type { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose'; +import type { ProductPhotoDraft } from './product-photo-gallery'; +import { normalizePhotoName } from './product-form-image-helpers'; + +export async function uploadProductPhoto( + file: File, + defaultName: string, + purpose: ProductImagePurpose, +): Promise { + const response = await fetch('/api/uploads/presigned-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: UploadType.product, + fileName: file.name, + mimeType: file.type, + size: file.size, + }), + }); + if (!response.ok) { + let detail = 'Upload failed'; + try { + const body = await response.json(); + if (body.error) detail = body.error; + } catch { + // The generic upload message preserves the existing fallback behavior. + } + throw new Error(detail); + } + + const result = (await response.json()) as { + id: string; + uploadUrl: string; + publicUrl: string; + }; + const uploadResponse = await fetch(result.uploadUrl, { + method: 'PUT', + headers: { 'content-type': file.type }, + body: file, + }); + if (!uploadResponse.ok) throw new Error('File storage failed'); + + return { + id: result.id, + url: toAbsoluteUrl(result.publicUrl), + alt: normalizePhotoName( + file.name.replace(/\.[^.]+$/, '').replaceAll(/[-_]+/g, ' '), + defaultName, + ), + size: file.size, + purpose, + mimeType: file.type, + posterUrl: null, + }; +} diff --git a/app/[locale]/seller/products/product-form-view.tsx b/app/[locale]/seller/products/product-form-view.tsx new file mode 100644 index 00000000..e6193a07 --- /dev/null +++ b/app/[locale]/seller/products/product-form-view.tsx @@ -0,0 +1,117 @@ +import { BackLink } from '@/shared/ui/back-link'; +import { Button } from '@/shared/ui/button'; +import { Card } from '@/shared/ui/card'; +import { PriceField } from '@/shared/ui/price-field'; +import { SelectField } from '@/shared/ui/select-field'; +import { ProductLocaleTabs } from '@/modules/products/presentation/components/product-locale-tabs'; +import { ProductTranslationSection } from '@/modules/products/presentation/components/product-translation-section'; +import { ProductFormPhotoGalleries } from './product-form-photo-galleries'; +import type { ProductFormProps } from './product-form-types'; +import type { ProductFormController } from './use-product-form'; +import styles from './product-form.module.css'; + +interface ProductFormViewProps { + locale: string; + labels: ProductFormProps['labels']; + categories: NonNullable; + controller: ProductFormController; +} + +export function ProductFormView({ + locale, + labels, + categories, + controller, +}: ProductFormViewProps) { + const { form, errors } = controller; + + return ( + <> + {controller.unsavedGuard} +
+ + {labels.backToProducts} + +
+
+
+

{labels.customization.label}

+

{labels.title}

+

{labels.customization.hint}

+
+
+ {controller.serverError ? ( +

+ {controller.serverError} +

+ ) : null} + {controller.saved ? ( +

+ {controller.saved} +

+ ) : null} +
+ +
+ +
+ +
+
+
+ controller.updateField('price', value)} + error={errors.price} + required + /> + + controller.updateField('customizationConfig', { + ...form.customizationConfig, + categoryId: value || null, + }) + } + options={categories.map((category) => ({ + value: category.id, + label: category.name, + }))} + placeholder={labels.customization.editor.categoryPlaceholder} + /> + {errors.images ? ( +

+ {errors.images} +

+ ) : null} + +
+
+ +
+
+
+
+ + ); +} diff --git a/app/[locale]/seller/products/product-form.tsx b/app/[locale]/seller/products/product-form.tsx index ce2e2727..767de942 100644 --- a/app/[locale]/seller/products/product-form.tsx +++ b/app/[locale]/seller/products/product-form.tsx @@ -1,610 +1,8 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useUnsavedChangesGuard } from '@/shared/hooks/use-unsaved-changes-guard'; -import { type ZodError } from 'zod'; -import { Button } from '@/shared/ui/button'; -import { BackLink } from '@/shared/ui/back-link'; -import { Card } from '@/shared/ui/card'; -import { PriceField } from '@/shared/ui/price-field'; -import { SelectField } from '@/shared/ui/select-field'; -import { - ProductLocaleTabs, - type ProductLocale, -} from '@/modules/products/presentation/components/product-locale-tabs'; -import { - ProductTranslationSection, - type ProductTranslationDraft, -} from '@/modules/products/presentation/components/product-translation-section'; -import type { ProductFormLabels } from '@/modules/products/presentation/product-form-labels'; -import { toAbsoluteUrl } from '@/shared/presentation/lib/to-absolute-url'; -import { UploadType } from '@/modules/uploads/domain/value-objects/upload-type'; -import { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose'; -import { - productFormSchema, - type ProductTranslationInput, - type ProductCustomizationConfigInput, -} from '@/modules/products/presentation/schemas/product-form-schema'; -import { - ProductPhotoBucketGallery, - type ProductPhotoDraft, -} from './product-photo-gallery'; -import styles from './product-form.module.css'; - -type ProductFormMode = 'create' | 'edit'; - -type SupportedLocale = ProductLocale; - -interface LocaleTranslationState extends ProductTranslationDraft { - locale: SupportedLocale; -} - -type TranslationMap = Record; - -interface CategoryOption { - id: string; - name: string; -} - -interface ProductFormImageSeed { - id?: string; - url: string; - alt: string | null; - purpose?: ProductImagePurpose; - mimeType?: string; - posterUrl?: string | null; -} - -interface ProductFormImageBucketsSeed { - cover: ProductFormImageSeed | null; - showcase: ProductFormImageSeed[]; - customizableBase: ProductFormImageSeed[]; -} - -type ProductFormImageSeeds = - Array | ProductFormImageBucketsSeed; - -interface ProductFormProps { - locale: string; - mode: ProductFormMode; - productId?: string; - initialValues: { - price: number; - name?: string; - description?: string; - translation?: ProductTranslationInput; - translations?: Array; - customizationConfig: ProductCustomizationConfigInput; - images: ProductFormImageSeeds; - }; - labels: ProductFormLabels; - categories?: CategoryOption[]; -} - -interface ProductPhotoBucketsState { - cover: ProductPhotoDraft | null; - showcase: ProductPhotoDraft[]; - customizableBase: ProductPhotoDraft[]; -} - -interface FormState { - price: string; - activeLocale: SupportedLocale; - translations: TranslationMap; - customizationConfig: ProductCustomizationConfigInput; - images: ProductPhotoBucketsState; - selectedPhotoId: string | null; -} - -interface FormErrors { - name?: string; - description?: string; - price?: string; - images?: string; - customizationConfig?: string; -} - -function buildDefaultPhotoName(labels: ProductFormLabels, index: number) { - return `${labels.gallery.defaultPhotoName} ${index + 1}`; -} - -function normalizePhotoName(value: string, fallback: string) { - const trimmed = value.trim(); - if (trimmed.length > 0) return trimmed; - - return fallback; -} - -type ProductPhotoBucket = keyof ProductPhotoBucketsState; - -interface ProductPhotoSeedEntry { - seed: ProductFormImageSeed; - bucket: ProductPhotoBucket; -} - -function bucketForPurpose( - purpose: ProductFormImageSeed['purpose'], -): ProductPhotoBucket { - if (purpose === ProductImagePurpose.COVER) return 'cover'; - if (purpose === ProductImagePurpose.CUSTOMIZABLE_BASE) { - return 'customizableBase'; - } - - return 'showcase'; -} - -function purposeForBucket(bucket: ProductPhotoBucket): ProductImagePurpose { - if (bucket === 'cover') return ProductImagePurpose.COVER; - if (bucket === 'customizableBase') { - return ProductImagePurpose.CUSTOMIZABLE_BASE; - } - - return ProductImagePurpose.SHOWCASE; -} - -function getBucketIndex( - bucket: ProductPhotoBucket, - indexes: { cover: number; showcase: number; customizableBase: number }, -): number { - if (bucket === 'cover') return indexes.cover; - if (bucket === 'customizableBase') return indexes.customizableBase; - - return indexes.showcase; -} - -function collectInitialImageSeedEntries( - images: ProductFormImageSeeds, -): ProductPhotoSeedEntry[] { - if (Array.isArray(images)) { - return images.map((seed) => ({ - seed, - bucket: bucketForPurpose(seed.purpose), - })); - } - - return [ - ...(images.cover ? [{ seed: images.cover, bucket: 'cover' as const }] : []), - ...images.showcase.map((seed) => ({ seed, bucket: 'showcase' as const })), - ...images.customizableBase.map((seed) => ({ - seed, - bucket: 'customizableBase' as const, - })), - ]; -} - -function populateInitialImageBuckets( - labels: ProductFormLabels, - buckets: ProductPhotoBucketsState, - seedEntries: ProductPhotoSeedEntry[], -) { - const indexes = { cover: 0, showcase: 0, customizableBase: 0 }; - - for (const entry of seedEntries) { - const bucketIndex = getBucketIndex(entry.bucket, indexes); - const draft = createPhotoDraft( - entry.seed, - buildDefaultPhotoName(labels, bucketIndex), - purposeForBucket(entry.bucket), - ); - - if (entry.bucket === 'cover') { - buckets.cover = draft; - indexes.cover += 1; - continue; - } - - if (entry.bucket === 'customizableBase') { - buckets.customizableBase.push(draft); - indexes.customizableBase += 1; - continue; - } - - buckets.showcase.push(draft); - indexes.showcase += 1; - } -} - -function createPhotoDraft( - seed: ProductFormImageSeed, - fallbackName: string, - purpose: ProductImagePurpose, -): ProductPhotoDraft { - return { - id: seed.id ?? createPhotoId(), - url: seed.url, - alt: normalizePhotoName(seed.alt ?? '', fallbackName), - size: null, - purpose: seed.purpose ?? purpose, - mimeType: seed.mimeType ?? 'image/jpeg', - posterUrl: seed.posterUrl ?? null, - }; -} - -function createEmptyBuckets(): ProductPhotoBucketsState { - return { - cover: null, - showcase: [], - customizableBase: [], - }; -} - -function normalizeInitialImages( - labels: ProductFormLabels, - images: ProductFormImageSeeds, -): ProductPhotoBucketsState { - const buckets = createEmptyBuckets(); - - populateInitialImageBuckets( - labels, - buckets, - collectInitialImageSeedEntries(images), - ); - - return buckets; -} - -function moveItem( - items: T[], - itemId: string, - direction: -1 | 1, -) { - const currentIndex = items.findIndex((item) => item.id === itemId); - const nextIndex = currentIndex + direction; - - if (currentIndex === -1 || nextIndex < 0 || nextIndex >= items.length) { - return items; - } - - const next = [...items]; - const [item] = next.splice(currentIndex, 1); - next.splice(nextIndex, 0, item); - return next; -} - -function updateBucketPhoto( - state: ProductPhotoBucketsState, - bucket: keyof ProductPhotoBucketsState, - photoId: string, - patch: Partial, -): ProductPhotoBucketsState { - if (bucket === 'cover') { - if (!state.cover || state.cover.id !== photoId) return state; - - return { ...state, cover: { ...state.cover, ...patch } }; - } - - return { - ...state, - [bucket]: state[bucket].map((photo) => - photo.id === photoId ? { ...photo, ...patch } : photo, - ), - }; -} - -function removeBucketPhoto( - state: ProductPhotoBucketsState, - bucket: keyof ProductPhotoBucketsState, - photoId: string, -): ProductPhotoBucketsState { - if (bucket === 'cover') { - if (!state.cover || state.cover.id !== photoId) return state; - return { ...state, cover: null }; - } - - return { - ...state, - [bucket]: state[bucket].filter((photo) => photo.id !== photoId), - }; -} - -function normalizeLocale(value: string): SupportedLocale { - return value === 'cat' ? 'cat' : 'es'; -} - -function createTranslationDraft( - locale: SupportedLocale, -): LocaleTranslationState { - return { - locale, - name: '', - description: '', - tags: [], - sizes: [], - designChangeDescription: null, - photoLabels: {}, - }; -} - -function normalizeTranslationDraft( - locale: SupportedLocale, - draft?: Partial, -): LocaleTranslationState { - return { - ...createTranslationDraft(locale), - ...draft, - locale, - tags: [...(draft?.tags ?? [])], - sizes: [...(draft?.sizes ?? [])], - designChangeDescription: draft?.designChangeDescription ?? null, - photoLabels: clonePhotoLabels(draft?.photoLabels), - }; -} - -function clonePhotoLabels(labels?: Record) { - return labels ? { ...labels } : {}; -} - -function cleanPhotoLabels( - labels: Record | undefined, - photoIds: ReadonlySet, -) { - return Object.fromEntries( - Object.entries(labels ?? {}).filter(([photoId]) => photoIds.has(photoId)), - ); -} - -function photoIdsFor(images: ProductPhotoBucketsState) { - return new Set(images.customizableBase.map((photo) => photo.id)); -} - -function addPhotoLabels( - translations: TranslationMap, - photoIds: readonly string[], -): TranslationMap { - if (photoIds.length === 0) return translations; - - return Object.fromEntries( - Object.entries(translations).map(([locale, translation]) => [ - locale, - { - ...translation, - photoLabels: { - ...translation.photoLabels, - ...Object.fromEntries(photoIds.map((photoId) => [photoId, ''])), - }, - }, - ]), - ) as TranslationMap; -} - -function removePhotoLabels( - translations: TranslationMap, - photoId: string, -): TranslationMap { - return Object.fromEntries( - Object.entries(translations).map(([locale, translation]) => { - const photoLabels = { ...translation.photoLabels }; - delete photoLabels[photoId]; - return [locale, { ...translation, photoLabels }]; - }), - ) as TranslationMap; -} - -function hasInactiveTranslationContent( - translation: LocaleTranslationState, -): boolean { - return ( - translation.description.trim().length > 0 || - translation.tags.length > 0 || - translation.sizes.length > 0 || - (translation.designChangeDescription?.trim().length ?? 0) > 0 || - Object.keys(translation.photoLabels ?? {}).length > 0 - ); -} - -function findTranslationWithMissingName( - form: FormState, -): SupportedLocale | null { - const activeTranslation = form.translations[form.activeLocale]; - if (activeTranslation.name.trim().length === 0) { - return form.activeLocale; - } - - for (const translation of Object.values(form.translations)) { - if (translation.locale === form.activeLocale) continue; - - if ( - translation.name.trim().length === 0 && - hasInactiveTranslationContent(translation) - ) { - return translation.locale; - } - } - - return null; -} - -function buildTranslationMap( - locale: SupportedLocale, - initialValues: ProductFormProps['initialValues'], -): TranslationMap { - const base: TranslationMap = { - es: createTranslationDraft('es'), - cat: createTranslationDraft('cat'), - }; - - if (initialValues.translations && initialValues.translations.length > 0) { - for (const draft of initialValues.translations) { - base[draft.locale] = normalizeTranslationDraft(draft.locale, draft); - } - return base; - } - - const active = normalizeTranslationDraft(locale, { - name: initialValues.name ?? '', - description: initialValues.description ?? '', - tags: initialValues.translation?.tags ?? [], - sizes: initialValues.translation?.sizes ?? [], - designChangeDescription: - initialValues.translation?.designChangeDescription ?? null, - photoLabels: initialValues.translation?.photoLabels ?? {}, - }); - - base[locale] = active; - return base; -} - -function buildPayload(locale: SupportedLocale, form: FormState) { - const current = form.translations[locale]; - const hasCustomizableBase = form.images.customizableBase.length > 0; - const effectiveMode = - hasCustomizableBase && form.customizationConfig?.mode === 'description' - ? 'text_photo' - : form.customizationConfig?.mode; - - const customizationConfig = form.customizationConfig - ? { - ...form.customizationConfig, - mode: effectiveMode, - } - : undefined; - const currentDesignChangeDescription = - current.designChangeDescription?.trim() ?? ''; - - const photoIds = photoIdsFor(form.images); - const translations = Object.values(form.translations) - .map((translation) => { - const name = translation.name.trim(); - const description = translation.description.trim(); - const designChangeDescription = - translation.designChangeDescription?.trim() ?? ''; - return { - locale: translation.locale, - name, - description: description.length > 0 ? description : undefined, - tags: [...translation.tags], - sizes: [...translation.sizes], - designChangeDescription: - designChangeDescription.length > 0 ? designChangeDescription : null, - photoLabels: cleanPhotoLabels(translation.photoLabels, photoIds), - }; - }) - .filter( - (translation) => - translation.name.length > 0 || - translation.description !== undefined || - translation.tags.length > 0 || - translation.sizes.length > 0 || - translation.designChangeDescription !== null || - Object.keys(translation.photoLabels ?? {}).length > 0, - ); - - const images = [ - ...(form.images.cover - ? [ - { - ...form.images.cover, - position: 0, - }, - ] - : []), - ...form.images.showcase.map((image, index) => ({ - ...image, - position: index, - })), - ...form.images.customizableBase.map((image, index) => ({ - ...image, - position: index, - })), - ].map((image) => ({ - id: image.id, - url: image.url, - alt: image.alt.trim(), - position: image.position, - purpose: image.purpose, - mimeType: image.mimeType, - posterUrl: image.posterUrl, - })); - - const payload = { - locale, - name: current.name.trim(), - description: current.description.trim() || undefined, - price: form.price, - translation: { - tags: [...current.tags], - sizes: [...current.sizes], - designChangeDescription: - currentDesignChangeDescription.length > 0 - ? currentDesignChangeDescription - : null, - photoLabels: cleanPhotoLabels(current.photoLabels, photoIds), - }, - translations, - customizationConfig, - images, - }; - - const result = productFormSchema.safeParse(payload); - if (!result.success) { - return { - success: false as const, - error: result.error, - payload, - }; - } - - return { success: true as const, payload: result.data }; -} - -async function uploadPhoto( - file: File, - defaultName: string, - purpose: ProductImagePurpose, -) { - const response = await fetch('/api/uploads/presigned-url', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: UploadType.product, - fileName: file.name, - mimeType: file.type, - size: file.size, - }), - }); - - if (!response.ok) { - let detail = 'Upload failed'; - try { - const body = await response.json(); - if (body.error) detail = body.error; - } catch { - /* empty */ - } - throw new Error(detail); - } - - const result = (await response.json()) as { - id: string; - uploadUrl: string; - storageKey: string; - publicUrl: string; - }; - - const uploadResponse = await fetch(result.uploadUrl, { - method: 'PUT', - headers: { 'content-type': file.type }, - body: file, - }); - - if (!uploadResponse.ok) { - throw new Error('File storage failed'); - } - - return { - id: result.id, - url: toAbsoluteUrl(result.publicUrl), - alt: normalizePhotoName( - file.name.replace(/\.[^.]+$/, '').replaceAll(/[-_]+/g, ' '), - defaultName, - ), - size: file.size, - purpose, - mimeType: file.type, - posterUrl: null, - } satisfies ProductPhotoDraft; -} +import { ProductFormView } from './product-form-view'; +import type { ProductFormProps } from './product-form-types'; +import { useProductForm } from './use-product-form'; export function ProductForm({ locale, @@ -614,501 +12,21 @@ export function ProductForm({ labels, categories = [], }: ProductFormProps) { - const router = useRouter(); - const initialLocale = normalizeLocale(locale); - const [form, setForm] = useState(() => { - const images = normalizeInitialImages(labels, initialValues.images); - const validPhotoIds = photoIdsFor(images); - const translations = Object.fromEntries( - Object.entries(buildTranslationMap(initialLocale, initialValues)).map( - ([translationLocale, translation]) => [ - translationLocale, - { - ...translation, - photoLabels: cleanPhotoLabels( - translation.photoLabels, - validPhotoIds, - ), - }, - ], - ), - ) as TranslationMap; - - return { - price: String(initialValues.price), - activeLocale: initialLocale, - translations, - customizationConfig: initialValues.customizationConfig, - images, - selectedPhotoId: - images.cover?.id ?? - images.showcase[0]?.id ?? - images.customizableBase[0]?.id ?? - null, - }; + const controller = useProductForm({ + locale, + mode, + productId, + initialValues, + labels, + categories, }); - const [errors, setErrors] = useState({}); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const [saved, setSaved] = useState(null); - const [serverError, setServerError] = useState(null); - const [photoError, setPhotoError] = useState(null); - const initialFormSnapshot = useMemo( - () => - JSON.stringify({ - price: String(initialValues.price), - translations: buildTranslationMap(initialLocale, initialValues), - customizationConfig: initialValues.customizationConfig, - }), - [initialLocale, initialValues], - ); - const unsavedGuard = useUnsavedChangesGuard( - mode === 'edit' && - JSON.stringify({ - price: form.price, - translations: form.translations, - customizationConfig: form.customizationConfig, - }) !== initialFormSnapshot, - labels.unsavedChanges ?? { - title: 'Unsaved changes', - message: 'You have unsaved changes. Leave this page?', - leave: 'Leave', - stay: 'Stay', - }, - ); - const photoLabels = labels.gallery; - - const endpoint = useMemo( - () => (mode === 'create' ? '/api/products' : `/api/products/${productId}`), - [mode, productId], - ); - - const updateField = < - K extends keyof Pick, - >( - field: K, - value: FormState[K], - ) => { - setForm((current) => ({ ...current, [field]: value })); - setErrors((current) => { - if (!Object.hasOwn(current, field)) return current; - const next = { ...current }; - delete next[field]; - return next; - }); - }; - - const updateLocale = (nextLocale: SupportedLocale) => { - setForm((current) => ({ ...current, activeLocale: nextLocale })); - }; - - const updateTranslation = (patch: Partial) => { - setForm((current) => { - const locale = current.activeLocale; - const nextTranslation = normalizeTranslationDraft(locale, { - ...current.translations[locale], - ...patch, - locale, - }); - - return { - ...current, - translations: { - ...current.translations, - [locale]: nextTranslation, - }, - }; - }); - - setErrors((current) => { - if (!current.name && !current.description) return current; - const next = { ...current }; - delete next.name; - delete next.description; - return next; - }); - }; - - const updatePhoto = ( - bucket: keyof ProductPhotoBucketsState, - photoId: string, - patch: Partial, - ) => { - setForm((current) => ({ - ...current, - images: updateBucketPhoto(current.images, bucket, photoId, patch), - })); - }; - - const updatePhotoLabel = (photoId: string, label: string) => { - setForm((current) => ({ - ...current, - translations: { - ...current.translations, - [current.activeLocale]: { - ...current.translations[current.activeLocale], - photoLabels: { - ...current.translations[current.activeLocale].photoLabels, - [photoId]: label, - }, - }, - }, - })); - }; - - const removePhoto = ( - bucket: keyof ProductPhotoBucketsState, - photoId: string, - ) => { - setForm((current) => { - const nextImages = removeBucketPhoto(current.images, bucket, photoId); - const nextTranslations = - bucket === 'customizableBase' - ? removePhotoLabels(current.translations, photoId) - : current.translations; - const nextSelected = - current.selectedPhotoId === photoId - ? (nextImages.cover?.id ?? - nextImages.showcase[0]?.id ?? - nextImages.customizableBase[0]?.id ?? - null) - : current.selectedPhotoId; - - return { - ...current, - images: nextImages, - translations: nextTranslations, - selectedPhotoId: nextSelected, - }; - }); - }; - - const selectPhoto = (photoId: string) => { - setForm((current) => ({ ...current, selectedPhotoId: photoId })); - }; - - const movePhoto = ( - bucket: keyof ProductPhotoBucketsState, - photoId: string, - direction: -1 | 1, - ) => { - if (bucket === 'cover') return; - - setForm((current) => ({ - ...current, - images: { - ...current.images, - [bucket]: moveItem(current.images[bucket], photoId, direction), - }, - })); - }; - - const handleUpload = async ( - bucket: keyof ProductPhotoBucketsState, - files: File[], - ) => { - if (files.length === 0) return; - - setUploading(true); - setPhotoError(null); - - try { - const purpose = purposeForBucket(bucket); - const existingCount = bucket === 'cover' ? 0 : form.images[bucket].length; - const uploads = await Promise.all( - files.map((file, index) => - uploadPhoto( - file, - buildDefaultPhotoName(labels, existingCount + index), - purpose, - ), - ), - ); - - setForm((current) => { - const nextImages = - bucket === 'cover' - ? { - ...current.images, - cover: uploads[0] ?? current.images.cover, - } - : { - ...current.images, - [bucket]: [...current.images[bucket], ...uploads], - }; - - return { - ...current, - images: nextImages, - translations: - bucket === 'customizableBase' - ? addPhotoLabels( - current.translations, - uploads.map((photo) => photo.id), - ) - : current.translations, - selectedPhotoId: - bucket === 'cover' - ? (uploads[0]?.id ?? current.selectedPhotoId ?? null) - : (current.selectedPhotoId ?? uploads[0]?.id ?? null), - }; - }); - } catch (error) { - setPhotoError( - error instanceof Error ? error.message : labels.gallery.uploadError, - ); - } finally { - setUploading(false); - } - }; - - const mapErrors = (error: ZodError): FormErrors => { - const next: FormErrors = {}; - - for (const issue of error.issues) { - const path = issue.path[0] as keyof FormErrors | undefined; - if (path !== undefined && !Object.hasOwn(next, path)) { - next[path] = issue.message; - } - } - - return next; - }; - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setServerError(null); - setSaved(null); - setErrors({}); - - const missingTranslationLocale = findTranslationWithMissingName(form); - if (missingTranslationLocale) { - setForm((current) => ({ - ...current, - activeLocale: missingTranslationLocale, - })); - setErrors({ - name: labels.missingTranslationNameError - .split('{locale}') - .join(missingTranslationLocale.toUpperCase()), - }); - return; - } - - const parsed = buildPayload(form.activeLocale, form); - - if (!parsed.success) { - setErrors(mapErrors(parsed.error)); - return; - } - - setLoading(true); - - try { - const response = await fetch(endpoint, { - method: mode === 'create' ? 'POST' : 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(parsed.payload), - }); - - if (!response.ok) { - let data: { error?: string } | null = null; - try { - data = (await response.json()) as { error?: string } | null; - } catch { - data = null; - } - throw new Error(data?.error || labels.error); - } - - setSaved(labels.saved); - router.push(`/${locale}/seller/products`); - router.refresh(); - } catch (error: unknown) { - setServerError(error instanceof Error ? error.message : labels.error); - } finally { - setLoading(false); - } - }; - - return ( - <> - {unsavedGuard} -
- - {labels.backToProducts} - - -
-
-
-

{labels.customization.label}

-

{labels.title}

-

{labels.customization.hint}

-
-
- - {serverError ? ( -

- {serverError} -

- ) : null} - {saved ? ( -

- {saved} -

- ) : null} - -
- -
- - -
- -
- -
- -
- updateField('price', v)} - error={errors.price} - required - /> - - - updateField('customizationConfig', { - ...form.customizationConfig, - categoryId: v || null, - }) - } - options={categories.map((c) => ({ - value: c.id, - label: c.name, - }))} - placeholder={labels.customization.editor.categoryPlaceholder} - /> - - {errors.images ? ( -

- {errors.images} -

- ) : null} - - {photoError ? ( -

- {photoError} -

- ) : null} - -
- handleUpload('cover', files)} - onPhotoLabelChange={(photoId, alt) => - updatePhoto('cover', photoId, { alt }) - } - onSelectPhoto={selectPhoto} - onRemovePhoto={(photoId) => removePhoto('cover', photoId)} - uploading={uploading} - error={null} - /> - - handleUpload('showcase', files)} - onPhotoLabelChange={(photoId, alt) => - updatePhoto('showcase', photoId, { alt }) - } - onSelectPhoto={selectPhoto} - onRemovePhoto={(photoId) => removePhoto('showcase', photoId)} - onMovePhotoUp={(photoId) => - movePhoto('showcase', photoId, -1) - } - onMovePhotoDown={(photoId) => - movePhoto('showcase', photoId, 1) - } - onPosterUrlChange={(photoId, posterUrl) => - updatePhoto('showcase', photoId, { - posterUrl: - posterUrl.trim().length > 0 ? posterUrl.trim() : null, - }) - } - uploading={uploading} - error={null} - /> - - - handleUpload('customizableBase', files) - } - onPhotoLabelChange={updatePhotoLabel} - onSelectPhoto={selectPhoto} - onRemovePhoto={(photoId) => - removePhoto('customizableBase', photoId) - } - onMovePhotoUp={(photoId) => - movePhoto('customizableBase', photoId, -1) - } - onMovePhotoDown={(photoId) => - movePhoto('customizableBase', photoId, 1) - } - uploading={uploading} - error={null} - /> -
-
- -
- -
-
-
-
- - ); -} -function createPhotoId() { return ( - globalThis.crypto?.randomUUID?.() ?? - `photo-${Date.now()}-${crypto.randomUUID()}` + ); } diff --git a/app/[locale]/seller/products/use-product-form-fields.ts b/app/[locale]/seller/products/use-product-form-fields.ts new file mode 100644 index 00000000..fc1a7af5 --- /dev/null +++ b/app/[locale]/seller/products/use-product-form-fields.ts @@ -0,0 +1,167 @@ +import { useCallback } from 'react'; +import type { ProductPhotoDraft } from './product-photo-gallery'; +import { + moveItem, + removeBucketPhoto, + updateBucketPhoto, +} from './product-form-image-helpers'; +import { + normalizeTranslationDraft, + removePhotoLabels, +} from './product-form-translation-helpers'; +import type { + FormErrors, + FormState, + LocaleTranslationState, + ProductPhotoBucket, + SupportedLocale, +} from './product-form-types'; + +interface ProductFormFieldsOptions { + setForm: React.Dispatch>; + setErrors: React.Dispatch>; +} + +export function useProductFormFields({ + setForm, + setErrors, +}: ProductFormFieldsOptions) { + const updateField = useCallback( + >( + field: K, + value: FormState[K], + ) => { + setForm((current) => ({ ...current, [field]: value })); + setErrors((current) => { + if (!Object.hasOwn(current, field)) return current; + const next = { ...current }; + delete next[field]; + return next; + }); + }, + [setErrors, setForm], + ); + + const updateLocale = useCallback( + (activeLocale: SupportedLocale) => { + setForm((current) => ({ ...current, activeLocale })); + }, + [setForm], + ); + + const updateTranslation = useCallback( + (patch: Partial) => { + setForm((current) => { + const activeLocale = current.activeLocale; + return { + ...current, + translations: { + ...current.translations, + [activeLocale]: normalizeTranslationDraft(activeLocale, { + ...current.translations[activeLocale], + ...patch, + locale: activeLocale, + }), + }, + }; + }); + setErrors((current) => { + if (!current.name && !current.description) return current; + const next = { ...current }; + delete next.name; + delete next.description; + return next; + }); + }, + [setErrors, setForm], + ); + + const updatePhoto = useCallback( + ( + bucket: ProductPhotoBucket, + photoId: string, + patch: Partial, + ) => { + setForm((current) => ({ + ...current, + images: updateBucketPhoto(current.images, bucket, photoId, patch), + })); + }, + [setForm], + ); + + const updatePhotoLabel = useCallback( + (photoId: string, label: string) => { + setForm((current) => ({ + ...current, + translations: { + ...current.translations, + [current.activeLocale]: { + ...current.translations[current.activeLocale], + photoLabels: { + ...current.translations[current.activeLocale].photoLabels, + [photoId]: label, + }, + }, + }, + })); + }, + [setForm], + ); + + const removePhoto = useCallback( + (bucket: ProductPhotoBucket, photoId: string) => { + setForm((current) => { + const images = removeBucketPhoto(current.images, bucket, photoId); + return { + ...current, + images, + translations: + bucket === 'customizableBase' + ? removePhotoLabels(current.translations, photoId) + : current.translations, + selectedPhotoId: + current.selectedPhotoId === photoId + ? (images.cover?.id ?? + images.showcase[0]?.id ?? + images.customizableBase[0]?.id ?? + null) + : current.selectedPhotoId, + }; + }); + }, + [setForm], + ); + + const selectPhoto = useCallback( + (selectedPhotoId: string) => { + setForm((current) => ({ ...current, selectedPhotoId })); + }, + [setForm], + ); + + const movePhoto = useCallback( + (bucket: ProductPhotoBucket, photoId: string, direction: -1 | 1) => { + if (bucket === 'cover') return; + setForm((current) => ({ + ...current, + images: { + ...current.images, + [bucket]: moveItem(current.images[bucket], photoId, direction), + }, + })); + }, + [setForm], + ); + + return { + updateField, + updateLocale, + updateTranslation, + updatePhoto, + updatePhotoLabel, + removePhoto, + selectPhoto, + movePhoto, + }; +} diff --git a/app/[locale]/seller/products/use-product-form-photos.ts b/app/[locale]/seller/products/use-product-form-photos.ts new file mode 100644 index 00000000..416cd27d --- /dev/null +++ b/app/[locale]/seller/products/use-product-form-photos.ts @@ -0,0 +1,82 @@ +import { useCallback } from 'react'; +import { addPhotoLabels } from './product-form-translation-helpers'; +import { + defaultPhotoName, + purposeForBucket, +} from './product-form-image-helpers'; +import type { + FormState, + ProductFormProps, + ProductPhotoBucket, +} from './product-form-types'; +import { uploadProductPhoto } from './product-form-upload'; + +interface ProductFormPhotosOptions { + form: FormState; + labels: ProductFormProps['labels']; + setForm: React.Dispatch>; + setUploading: React.Dispatch>; + setPhotoError: React.Dispatch>; +} + +export function useProductFormPhotos({ + form, + labels, + setForm, + setUploading, + setPhotoError, +}: ProductFormPhotosOptions) { + const handleUpload = useCallback( + async (bucket: ProductPhotoBucket, files: File[]) => { + if (files.length === 0) return; + setUploading(true); + setPhotoError(null); + try { + const existingCount = + bucket === 'cover' ? 0 : form.images[bucket].length; + const uploads = await Promise.all( + files.map((file, index) => + uploadProductPhoto( + file, + defaultPhotoName(labels, existingCount + index), + purposeForBucket(bucket), + ), + ), + ); + setForm((current) => { + const images = + bucket === 'cover' + ? { ...current.images, cover: uploads[0] ?? current.images.cover } + : { + ...current.images, + [bucket]: [...current.images[bucket], ...uploads], + }; + return { + ...current, + images, + translations: + bucket === 'customizableBase' + ? addPhotoLabels( + current.translations, + uploads.map((photo) => photo.id), + ) + : current.translations, + selectedPhotoId: + bucket === 'cover' + ? (uploads[0]?.id ?? current.selectedPhotoId ?? null) + : (current.selectedPhotoId ?? uploads[0]?.id ?? null), + }; + }); + } catch (error) { + setPhotoError( + error instanceof Error ? error.message : labels.gallery.uploadError, + ); + } finally { + setUploading(false); + } + }, + [form.images, labels, setForm, setPhotoError, setUploading], + ); + + return { handleUpload }; +} diff --git a/app/[locale]/seller/products/use-product-form-submission.ts b/app/[locale]/seller/products/use-product-form-submission.ts new file mode 100644 index 00000000..08666d4b --- /dev/null +++ b/app/[locale]/seller/products/use-product-form-submission.ts @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { buildPayload, mapErrors } from './product-form-payload'; +import { findTranslationWithMissingName } from './product-form-translation-helpers'; +import type { + FormErrors, + FormState, + ProductFormProps, +} from './product-form-types'; + +interface ProductFormSubmissionOptions { + locale: string; + mode: ProductFormProps['mode']; + productId?: string; + form: FormState; + labels: ProductFormProps['labels']; + setForm: React.Dispatch>; + setErrors: React.Dispatch>; + setLoading: React.Dispatch>; + setSaved: React.Dispatch>; + setServerError: React.Dispatch>; +} + +export function useProductFormSubmission({ + locale, + mode, + productId, + form, + labels, + setForm, + setErrors, + setLoading, + setSaved, + setServerError, +}: ProductFormSubmissionOptions) { + const router = useRouter(); + const endpoint = + mode === 'create' ? '/api/products' : `/api/products/${productId}`; + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + setServerError(null); + setSaved(null); + setErrors({}); + const missingLocale = findTranslationWithMissingName(form); + if (missingLocale) { + setForm((current) => ({ ...current, activeLocale: missingLocale })); + setErrors({ + name: labels.missingTranslationNameError + .split('{locale}') + .join(missingLocale.toUpperCase()), + }); + return; + } + const parsed = buildPayload(form.activeLocale, form); + if (!parsed.success) { + setErrors(mapErrors(parsed.error)); + return; + } + setLoading(true); + try { + const response = await fetch(endpoint, { + method: mode === 'create' ? 'POST' : 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(parsed.payload), + }); + if (!response.ok) { + let data: { error?: string } | null = null; + try { + data = (await response.json()) as { error?: string } | null; + } catch { + data = null; + } + throw new Error(data?.error || labels.error); + } + setSaved(labels.saved); + router.push(`/${locale}/seller/products`); + router.refresh(); + } catch (error) { + setServerError(error instanceof Error ? error.message : labels.error); + } finally { + setLoading(false); + } + }, + [ + endpoint, + form, + labels, + locale, + mode, + router, + setErrors, + setForm, + setLoading, + setSaved, + setServerError, + ], + ); + + return { handleSubmit }; +} diff --git a/app/[locale]/seller/products/use-product-form.ts b/app/[locale]/seller/products/use-product-form.ts new file mode 100644 index 00000000..e727dced --- /dev/null +++ b/app/[locale]/seller/products/use-product-form.ts @@ -0,0 +1,155 @@ +import { useMemo, useState } from 'react'; +import { useUnsavedChangesGuard } from '@/shared/hooks/use-unsaved-changes-guard'; +import { + cleanPhotoLabels, + buildTranslationMap, + normalizeLocale, +} from './product-form-translation-helpers'; +import { + normalizeInitialImages, + photoIdsFor, +} from './product-form-image-helpers'; +import { useProductFormFields } from './use-product-form-fields'; +import { useProductFormPhotos } from './use-product-form-photos'; +import { useProductFormSubmission } from './use-product-form-submission'; +import type { + FormErrors, + FormState, + LocaleTranslationState, + ProductFormProps, + ProductPhotoBucket, + SupportedLocale, + TranslationMap, +} from './product-form-types'; +import type { ProductPhotoDraft } from './product-photo-gallery'; + +export interface ProductFormController { + form: FormState; + errors: FormErrors; + loading: boolean; + uploading: boolean; + saved: string | null; + serverError: string | null; + photoError: string | null; + unsavedGuard: React.ReactNode; + updateField: < + K extends keyof Pick, + >( + field: K, + value: FormState[K], + ) => void; + updateLocale: (locale: SupportedLocale) => void; + updateTranslation: (patch: Partial) => void; + updatePhoto: ( + bucket: ProductPhotoBucket, + photoId: string, + patch: Partial, + ) => void; + updatePhotoLabel: (photoId: string, label: string) => void; + removePhoto: (bucket: ProductPhotoBucket, photoId: string) => void; + selectPhoto: (photoId: string) => void; + movePhoto: ( + bucket: ProductPhotoBucket, + photoId: string, + direction: -1 | 1, + ) => void; + handleUpload: (bucket: ProductPhotoBucket, files: File[]) => Promise; + handleSubmit: (event: React.FormEvent) => Promise; +} + +export function useProductForm(props: ProductFormProps): ProductFormController { + const { locale, mode, productId, initialValues, labels } = props; + const initialLocale = normalizeLocale(locale); + const [form, setForm] = useState(() => { + const images = normalizeInitialImages(labels, initialValues.images); + const photoIds = photoIdsFor(images); + const translations = Object.fromEntries( + Object.entries(buildTranslationMap(initialLocale, initialValues)).map( + ([translationLocale, translation]) => [ + translationLocale, + { + ...translation, + photoLabels: cleanPhotoLabels(translation.photoLabels, photoIds), + }, + ], + ), + ) as TranslationMap; + return { + price: String(initialValues.price), + activeLocale: initialLocale, + translations, + customizationConfig: initialValues.customizationConfig, + images, + selectedPhotoId: + images.cover?.id ?? + images.showcase[0]?.id ?? + images.customizableBase[0]?.id ?? + null, + }; + }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [saved, setSaved] = useState(null); + const [serverError, setServerError] = useState(null); + const [photoError, setPhotoError] = useState(null); + const initialFormSnapshot = useMemo( + () => + JSON.stringify({ + price: String(initialValues.price), + translations: buildTranslationMap(initialLocale, initialValues), + customizationConfig: initialValues.customizationConfig, + images: normalizeInitialImages(labels, initialValues.images), + }), + [initialLocale, initialValues, labels], + ); + const unsavedGuard = useUnsavedChangesGuard( + mode === 'edit' && + JSON.stringify({ + price: form.price, + translations: form.translations, + customizationConfig: form.customizationConfig, + images: form.images, + }) !== initialFormSnapshot, + labels.unsavedChanges ?? { + title: 'Unsaved changes', + message: 'You have unsaved changes. Leave this page?', + leave: 'Leave', + stay: 'Stay', + }, + ); + const fields = useProductFormFields({ setForm, setErrors }); + const { handleUpload } = useProductFormPhotos({ + form, + labels, + setForm, + setUploading, + setPhotoError, + }); + const { handleSubmit } = useProductFormSubmission({ + locale, + mode, + productId, + form, + labels, + setForm, + setErrors, + setLoading, + setSaved, + setServerError, + }); + + return { + form, + errors, + loading, + uploading, + saved, + serverError, + photoError, + unsavedGuard, + ...fields, + handleUpload, + handleSubmit, + }; +} diff --git a/app/api/cart/items/[itemId]/route.ts b/app/api/cart/items/[itemId]/route.ts index f5d88405..d6d0b675 100644 --- a/app/api/cart/items/[itemId]/route.ts +++ b/app/api/cart/items/[itemId]/route.ts @@ -38,10 +38,14 @@ export const PATCH = requireRole('CUSTOMER')(async function PATCH( const cartRepository = container.getCartRepository(); const outboxRepository = container.getOutboxRepository(); const customizationLookup = container.getCustomizationLookup(); + const transactionRunner = container.getTransactionRunner(); + const customizationCreator = container.getCustomerCustomizationCreator(); const updateCartItem = new UpdateCartItemQuantity( cartRepository, outboxRepository, + transactionRunner, + customizationCreator, ); const item = await updateCartItem.execute({ @@ -49,6 +53,7 @@ export const PATCH = requireRole('CUSTOMER')(async function PATCH( itemId, quantity: validated.quantity, customizationIdList: validated.customizationIdList, + customization: validated.customization, }); // Enrich the item with product display data + resolved customizations. diff --git a/app/api/cart/items/route.ts b/app/api/cart/items/route.ts index e6e2ecf6..89912b74 100644 --- a/app/api/cart/items/route.ts +++ b/app/api/cart/items/route.ts @@ -34,12 +34,16 @@ export const POST = requireRole('CUSTOMER')(async function POST( const productRepository = container.getCartProductRepository(); const outboxRepository = container.getOutboxRepository(); const customizationLookup = container.getCustomizationLookup(); + const transactionRunner = container.getTransactionRunner(); + const customizationCreator = container.getCustomerCustomizationCreator(); const addItemToCart = new AddItemToCart( cartRepository, productRepository, outboxRepository, customizationLookup, + transactionRunner, + customizationCreator, ); const item = await addItemToCart.execute({ @@ -47,6 +51,7 @@ export const POST = requireRole('CUSTOMER')(async function POST( productId: validated.productId, quantity: validated.quantity, customizationIdList: validated.customizationIdList, + customization: validated.customization, }); // Enrich the item with product display data + resolved customizations. diff --git a/composition-root/container.ts b/composition-root/container.ts index 99d49746..0469cfc0 100644 --- a/composition-root/container.ts +++ b/composition-root/container.ts @@ -55,7 +55,9 @@ import type { CartRepository } from '@/modules/cart/domain/cart-repository'; import type { ProductRepository as CartProductRepository } from '@/modules/cart/domain/product-repository'; import type { PaidOrderCountPort } from '@/modules/cart/domain/paid-order-count-port'; import type { CustomizationLookupPort as CartCustomizationLookupPort } from '@/modules/cart/domain/customization-lookup-port'; +import type { CustomerCustomizationCreatePort } from '@/modules/cart/domain/customer-customization-create-port'; import type { CustomizationRepository } from '@/modules/customizations/domain/customization-repository'; +import type { ProductCapabilityPort } from '@/modules/products/domain/product-capability-port'; import type { SearchHistoryRepository } from '@/modules/search-history/domain/search-history-repository'; import type { EmailUserLookupPort } from '@/modules/email/domain/ports/email-user-lookup-port'; import type { EmailOrderLookupPort } from '@/modules/email/domain/ports/email-order-lookup-port'; @@ -101,6 +103,7 @@ import { HandleCartCheckedOut } from '@/modules/orders/application/handle-cart-c import { MarkAsPaidUseCase } from '@/modules/orders/application/mark-as-paid-use-case'; import { TransactionalOrderService } from '@/modules/orders/infrastructure/transactional-order-service'; import { PrismaCustomizationRepository } from '@/modules/customizations/infrastructure/prisma-customization-repository'; +import { CartCustomerCustomizationCreator } from '@/modules/customizations/infrastructure/cart-customer-customization-creator'; import { PrismaSearchHistoryRepository } from '@/modules/search-history/infrastructure/prisma-search-history-repository'; import { HandleProductSearchExecuted } from '@/modules/search-history/application/handle-product-search-executed'; import { RecordSearchUseCase } from '@/modules/search-history/application/record-search-use-case'; @@ -171,6 +174,7 @@ export function initContainer(): void { getPaidOrderCountPort(); getCustomizationRepository(); getCustomizationLookup(); + getCustomerCustomizationCreator(); getSearchHistoryRepository(); getCategoryRepository(); getResetTokenCodec(); @@ -472,6 +476,22 @@ export function getCustomizationLookup(): CartCustomizationLookupPort { return state.customizationLookup as CartCustomizationLookupPort; } +export function getCustomerCustomizationCreator(): CustomerCustomizationCreatePort { + if (!state.customerCustomizationCreator) { + const productCapability: ProductCapabilityPort = { + async getConfig(productId) { + const product = await getProductRepository().findById(productId, 'es'); + return product?.customizationConfig ?? null; + }, + }; + state.customerCustomizationCreator = new CartCustomerCustomizationCreator( + getCustomizationRepository(), + productCapability, + ); + } + return state.customerCustomizationCreator as CustomerCustomizationCreatePort; +} + export function getSearchHistoryRepository(): SearchHistoryRepository { state.searchHistoryRepository ??= new PrismaSearchHistoryRepository(); return state.searchHistoryRepository as SearchHistoryRepository; @@ -527,6 +547,7 @@ export const container = { getCartProductRepository, getPaidOrderCountPort, getCustomizationLookup, + getCustomerCustomizationCreator, getCustomizationRepository, getSearchHistoryRepository, getCategoryRepository, diff --git a/modules/cart/application/add-item-to-cart.ts b/modules/cart/application/add-item-to-cart.ts index acd8bcdb..169c6eee 100644 --- a/modules/cart/application/add-item-to-cart.ts +++ b/modules/cart/application/add-item-to-cart.ts @@ -2,6 +2,10 @@ import type { CartRepository } from '../domain/cart-repository'; import type { CartItemEntity } from '../domain/entities/cart-item'; import type { ProductRepository } from '../domain/product-repository'; import type { CustomizationLookupPort } from '../domain/customization-lookup-port'; +import type { + CustomerCustomizationCreatePort, + CustomerCustomizationInput, +} from '../domain/customer-customization-create-port'; import { CartStatus } from '../domain/value-objects/cart-status'; import { Quantity } from '../domain/value-objects/quantity'; import { ProductId } from '@/shared/kernel/domain/value-objects/product-id'; @@ -21,6 +25,7 @@ export interface AddItemToCartDTO { productId: string; quantity: number; customizationIdList?: string[]; + customization?: CustomerCustomizationInput; } // --- Use Case --- @@ -54,6 +59,7 @@ export class AddItemToCart { private outboxRepository: OutboxRepository, private customizationLookup: CustomizationLookupPort, private txRunner?: TransactionRunner, + private customizationCreator?: CustomerCustomizationCreatePort, ) {} /** @@ -124,6 +130,24 @@ export class AddItemToCart { ); } + if (dto.customization) { + if (!this.customizationCreator) { + throw new Error('Customer customization creator is not configured'); + } + const customization = await this.customizationCreator.create( + { productId: dto.productId, ...dto.customization }, + dto.userId, + tx, + ); + if (customization.productId !== dto.productId) { + throw new InvalidCustomizationError( + `Customization ${customization.id} does not belong to product ${dto.productId}`, + 'Customization is not available for this product', + ); + } + customizationIdList.push(customization.id); + } + // 4. Find or create the ACTIVE cart for the user. Spec REQ-CART-001 // states a user has at most one ACTIVE cart. A user with only a // CHECKED_OUT cart (history) still gets a fresh ACTIVE cart. diff --git a/modules/cart/application/update-cart-item.ts b/modules/cart/application/update-cart-item.ts index c5cd6439..29190cca 100644 --- a/modules/cart/application/update-cart-item.ts +++ b/modules/cart/application/update-cart-item.ts @@ -4,6 +4,11 @@ import { Quantity } from '../domain/value-objects/quantity'; import type { OutboxRepository } from '@/shared/kernel/outbox-repository'; import { GlobalEvents } from '@/modules/events/domain/event-registry'; import type { CartItemEntity } from '../domain/entities/cart-item'; +import type { TransactionRunner } from '@/shared/kernel/transaction-runner'; +import type { + CustomerCustomizationCreatePort, + CustomerCustomizationInput, +} from '../domain/customer-customization-create-port'; // --- Data Transfer Object --- @@ -12,6 +17,7 @@ export interface UpdateCartItemQuantityDTO { itemId: string; quantity: number; customizationIdList?: string[]; + customization?: CustomerCustomizationInput; } // --- Use Case --- @@ -30,44 +36,74 @@ export class UpdateCartItemQuantity { constructor( private cartRepository: CartRepository, private outboxRepository: OutboxRepository, + private txRunner?: TransactionRunner, + private customizationCreator?: CustomerCustomizationCreatePort, ) {} async execute(dto: UpdateCartItemQuantityDTO): Promise { - // 1. Validate quantity. - const quantity = Quantity.create(dto.quantity); + const run = (fn: (tx: unknown) => Promise) => + this.txRunner ? this.txRunner.run(fn) : fn(undefined); - const { item, cart } = await loadAndVerifyCart( - this.cartRepository, - dto.userId, - dto.itemId, - ); + return run(async (tx) => { + // 1. Validate quantity. + const quantity = Quantity.create(dto.quantity); - // 6. Update the item, preserving the snapshot. - const updatedItem: CartItemEntity = { - ...item, - quantity: quantity.value, - ...(dto.customizationIdList !== undefined && { - customizationIdList: dto.customizationIdList, - }), - }; - const updatedItems = cart.items.map((i: CartItemEntity) => - i.id === item.id ? updatedItem : i, - ); + const { item, cart } = await loadAndVerifyCart( + this.cartRepository, + dto.userId, + dto.itemId, + ); - await this.cartRepository.save({ - ...cart, - items: updatedItems, - updatedAt: new Date(), - }); + let customizationIdList = dto.customizationIdList; + if (dto.customization) { + if (!this.customizationCreator) { + throw new Error('Customer customization creator is not configured'); + } + const customization = await this.customizationCreator.create( + { productId: item.productId.value, ...dto.customization }, + dto.userId, + tx, + ); + if (customization.productId !== item.productId.value) { + throw new Error( + 'Created customization does not belong to the cart item product', + ); + } + customizationIdList = [customization.id]; + } - // 7. Emit event. - await this.outboxRepository.saveEvent(GlobalEvents.CART_ITEM_UPDATED, { - cartId: cart.id, - itemId: updatedItem.id, - quantity: updatedItem.quantity, - occurredAt: new Date().toISOString(), - }); + // 6. Update the item, preserving the snapshot. + const updatedItem: CartItemEntity = { + ...item, + quantity: quantity.value, + ...(customizationIdList !== undefined && { customizationIdList }), + }; + const updatedItems = cart.items.map((i: CartItemEntity) => + i.id === item.id ? updatedItem : i, + ); + + await this.cartRepository.save( + { + ...cart, + items: updatedItems, + updatedAt: new Date(), + }, + tx, + ); - return updatedItem; + // 7. Emit event. + await this.outboxRepository.saveEvent( + GlobalEvents.CART_ITEM_UPDATED, + { + cartId: cart.id, + itemId: updatedItem.id, + quantity: updatedItem.quantity, + occurredAt: new Date().toISOString(), + }, + tx, + ); + + return updatedItem; + }); } } diff --git a/modules/cart/domain/customer-customization-create-port.ts b/modules/cart/domain/customer-customization-create-port.ts new file mode 100644 index 00000000..15c09239 --- /dev/null +++ b/modules/cart/domain/customer-customization-create-port.ts @@ -0,0 +1,21 @@ +import type { CustomizationDesignPositionSnapshot } from './customization-lookup-port'; + +export interface CustomerCustomizationInput { + text?: string | null; + color?: string | null; + size?: string | null; + imageUrl?: string | null; + designPosition?: CustomizationDesignPositionSnapshot | null; +} + +/** + * Cart-owned port for creating a buyer customization as part of a cart write. + * The adapter must use the supplied transaction client for its persistence. + */ +export interface CustomerCustomizationCreatePort { + create( + input: CustomerCustomizationInput & { productId: string }, + userId: string, + tx?: unknown, + ): Promise<{ id: string; productId: string }>; +} diff --git a/modules/cart/presentation/components/add-to-cart-button-view.tsx b/modules/cart/presentation/components/add-to-cart-button-view.tsx new file mode 100644 index 00000000..7b69dd61 --- /dev/null +++ b/modules/cart/presentation/components/add-to-cart-button-view.tsx @@ -0,0 +1,175 @@ +import { AddToCartChoiceModal } from './add-to-cart-choice-modal'; +import type { ButtonState, CartButtonLabels } from './add-to-cart-types'; +import styles from './add-to-cart-button.module.css'; + +const MAX_QUANTITY = 99; + +interface AddToCartButtonViewProps { + state: ButtonState; + quantity: number; + isInCart: boolean; + productInCart: boolean; + isAuthenticated: boolean; + hasCustomization: boolean; + showCustomizationChoice: boolean; + savingDesign: boolean; + disabled: boolean; + labels: CartButtonLabels; + customizeHref?: string; + customizationChoiceTitle: string; + onAdd: (event: React.MouseEvent) => Promise; + onAddAnother: (event: React.MouseEvent) => Promise; + onAddWithoutCustomization: () => Promise; + onCloseChoice: () => void; + onIncrement: () => Promise; + onDecrement: () => Promise; + onRemove: () => Promise; + onSaveDesign: () => Promise; +} + +function feedbackFor(state: ButtonState, labels: CartButtonLabels) { + if (state === 'adding') return labels.adding; + if (state === 'success') return labels.added; + if (state === 'error') return labels.error; + return labels.addToCart; +} + +function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { + const { labels, quantity, savingDesign, state } = props; + return ( +
+
+ + + {quantity} + + +
+ {props.hasCustomization && labels.saveDesign ? ( + + ) : null} + {props.hasCustomization && labels.addAnotherPersonalization ? ( + + ) : null} + +
+ ); +} + +function AddAction({ props }: { props: AddToCartButtonViewProps }) { + const { labels, state } = props; + return ( + <> + +
+ +
+ + ); +} + +export function AddToCartButtonView(props: AddToCartButtonViewProps) { + const feedback = feedbackFor(props.state, props.labels); + if (props.state === 'success' || props.state === 'error') { + const className = props.state === 'success' ? styles.success : styles.error; + return ( + + ); + } + if (props.isInCart) return ; + if (props.productInCart && props.hasCustomization && props.isAuthenticated) { + return ( +
+ +
+ ); + } + return ; +} diff --git a/modules/cart/presentation/components/add-to-cart-button.tsx b/modules/cart/presentation/components/add-to-cart-button.tsx index 3de08204..0955d369 100644 --- a/modules/cart/presentation/components/add-to-cart-button.tsx +++ b/modules/cart/presentation/components/add-to-cart-button.tsx @@ -1,739 +1,15 @@ 'use client'; -import { useState, useCallback, useEffect, useMemo } from 'react'; -import { useSession } from 'next-auth/react'; -import { useGuestCart } from '@/modules/cart/presentation/guest-cart-context'; -import { dispatchCartUpdated } from '@/modules/cart/presentation/cart-events'; -import { AddToCartChoiceModal } from './add-to-cart-choice-modal'; -import { - customizationDraftSchema, - normalizeCustomizationDraft, - type CustomizationDraftPayload, -} from './customization-draft-schema'; -import styles from './add-to-cart-button.module.css'; +import { AddToCartButtonView } from './add-to-cart-button-view'; +import type { AddToCartButtonProps } from './add-to-cart-types'; +import { useAddToCartButton } from './use-add-to-cart-button'; -const hasText = (value?: string | null) => value != null && value.length > 0; +export type { CartButtonLabels } from './add-to-cart-types'; -export interface CartButtonLabels { - addToCart: string; - removeFromCart: string; - adding: string; - added: string; - error: string; - customizeProduct?: string; - addWithoutCustomization?: string; - customizationChoiceBadge?: string; - customizationChoiceTitle?: string; - customizationChoiceMessage?: string; - decreaseQuantity?: string; - increaseQuantity?: string; - saveDesign?: string; - savingDesign?: string; - addAnotherPersonalization?: string; - alreadyInCart?: string; -} - -interface AddToCartButtonProps { - productId: string; - productName: string; - sellerId: string; - sellerName: string; - price: number; - imageUrl?: string | null; - customization?: CustomizationDraftPayload | null; - customizationAvailable?: boolean; - customizeHref?: string; - disabled?: boolean; - editCartItemId?: string; - labels: CartButtonLabels; -} - -interface CartItemInfo { - cartItemId: string; - quantity: number; -} - -type ButtonState = 'idle' | 'adding' | 'success' | 'error'; -const MAX_QUANTITY = 99; - -function isCustomizationMatching( - fields: { - text?: string | null; - color?: string | null; - size?: string | null; - imageUrl?: string | null; - }, - draft: CustomizationDraftPayload | null, -): boolean { - const norm = normalizeCustomizationDraft(draft); - return ( - (fields.text ?? null) === (norm.text ?? null) && - (fields.color ?? null) === (norm.color ?? null) && - (fields.size ?? null) === (norm.size ?? null) && - (fields.imageUrl ?? null) === (norm.imageUrl ?? null) - ); -} - -function isAuthCustomizationMatching( - customizations: Array<{ - text?: string | null; - color?: string | null; - size?: string | null; - imageUrl?: string | null; - }>, - draft: CustomizationDraftPayload | null, -): boolean { - const norm = normalizeCustomizationDraft(draft); - const hasDraftContent = - hasText(norm.text) || - hasText(norm.color) || - hasText(norm.size) || - hasText(norm.imageUrl) || - Boolean(norm.designPosition); - - if (!hasDraftContent) { - return customizations.length === 0; - } - - return customizations.some((c) => isCustomizationMatching(c, draft)); -} - -function findCartItemInfo( - items: Array<{ - id: string; - productId: string; - quantity: number; - customizations?: Array<{ - text?: string | null; - color?: string | null; - size?: string | null; - imageUrl?: string | null; - }>; - }>, - productId: string, - normalizedCustomization: CustomizationDraftPayload, -): { found: { cartItemId: string; quantity: number } | null } { - const found = items.find( - (item) => - item.productId === productId && - isAuthCustomizationMatching( - item.customizations ?? [], - normalizedCustomization, - ), - ); - return { - found: found ? { cartItemId: found.id, quantity: found.quantity } : null, - }; -} - -function findCartItemById( - items: Array<{ id: string; quantity: number }>, - cartItemId: string, -): CartItemInfo | null { - const item = items.find((candidate) => candidate.id === cartItemId); - return item ? { cartItemId: item.id, quantity: item.quantity } : null; -} - -/** - * Cart button with quantity controls. - * - * - Not in cart → "Add to Cart" button. - * - In cart → [- qty +] + "Remove" button. - * - * Guest: GuestCartContext. Auth: API calls. - */ -export function AddToCartButton({ - productId, - productName, - sellerId, - sellerName, - price, - imageUrl = null, - customization = null, - customizationAvailable = false, - customizeHref, - disabled = false, - editCartItemId, - labels, -}: AddToCartButtonProps) { - const { status } = useSession(); - const { - items, - addItem, - updateItemQuantity, - removeItemById, - updateItemCustomization, - } = useGuestCart(); - const isAuthenticated = status === 'authenticated'; - - const [state, setState] = useState('idle'); - const [cartItemInfo, setCartItemInfo] = useState(null); - const [productInCart, setProductInCart] = useState(false); - const [showCustomizationChoice, setShowCustomizationChoice] = useState(false); - const [savingDesign, setSavingDesign] = useState(false); - - const normalizedCustomization = useMemo( - () => normalizeCustomizationDraft(customization), - [customization], - ); - const isCustomizationHasContent = - hasText(normalizedCustomization.text) || - hasText(normalizedCustomization.color) || - hasText(normalizedCustomization.size) || - hasText(normalizedCustomization.imageUrl) || - Boolean(normalizedCustomization.designPosition); - - // Fetch cart for authenticated users. - useEffect(() => { - if (!isAuthenticated) return; - let isCancelled = false; - - async function fetchCart() { - try { - const res = await fetch('/api/cart'); - if (isCancelled || !res.ok) return; - const data = await res.json(); - if (isCancelled) return; - const result = editCartItemId - ? { - found: findCartItemById(data.items ?? [], editCartItemId), - } - : findCartItemInfo( - data.items ?? [], - productId, - normalizedCustomization, - ); - setCartItemInfo(result.found); - setProductInCart( - !editCartItemId && - result.found === null && - (data.items ?? []).some( - (item: { productId: string }) => item.productId === productId, - ), - ); - } catch { - /* fallback to "Add to Cart" */ - } - } - - fetchCart(); - return () => { - isCancelled = true; - }; - }, [editCartItemId, isAuthenticated, productId, normalizedCustomization]); - - // Determine current quantity (match by productId + customization). - const guestMatch = isAuthenticated - ? undefined - : items.find( - (i) => - i.productId === productId && - isCustomizationMatching( - { - text: i.customizationText, - color: i.customizationColor, - size: i.customizationSize, - imageUrl: i.customizationImageUrl, - }, - normalizedCustomization, - ), - ); - const currentQuantity = isAuthenticated - ? (cartItemInfo?.quantity ?? 0) - : (guestMatch?.quantity ?? 0); - - const isInCart = currentQuantity > 0; - - const customizeProductLabel = labels.customizeProduct ?? 'Customize'; - const addWithoutCustomizationLabel = - labels.addWithoutCustomization ?? 'Add without customization'; - const customizationChoiceBadgeLabel = - labels.customizationChoiceBadge ?? 'Customizable product'; - const customizationChoiceTitle = - labels.customizationChoiceTitle ?? - `${customizeProductLabel} ${productName}`; - const customizationChoiceMessage = - labels.customizationChoiceMessage ?? - 'You can customize this product first or add it as-is.'; - const decreaseQuantityLabel = labels.decreaseQuantity ?? 'Decrease quantity'; - const increaseQuantityLabel = labels.increaseQuantity ?? 'Increase quantity'; - - // Refresh cart after add (authenticated). - const refreshCartForProduct = useCallback(async () => { - if (!isAuthenticated) return; - try { - const res = await fetch('/api/cart'); - if (!res.ok) return; - const data = await res.json(); - const result = findCartItemInfo( - data.items ?? [], - productId, - normalizedCustomization, - ); - setCartItemInfo(result.found); - setProductInCart( - result.found === null && - (data.items ?? []).some( - (item: { productId: string }) => item.productId === productId, - ), - ); - } catch { - /* ignore */ - } - }, [isAuthenticated, productId, normalizedCustomization]); - - const handleSaveDesign = useCallback(async () => { - const draft = normalizeCustomizationDraft(customization); - const validation = customizationDraftSchema.safeParse(draft); - if (!validation.success) return; - - setSavingDesign(true); - - try { - if (isAuthenticated) { - const customizationResponse = await fetch( - '/api/customizations/customer', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - productId, - text: draft.text, - color: draft.color, - size: draft.size, - imageUrl: draft.imageUrl, - designPosition: draft.designPosition ?? null, - }), - }, - ); - - if (!customizationResponse.ok) { - setSavingDesign(false); - return; - } - - const customizationData = (await customizationResponse.json()) as { - id?: string; - }; - const newId = customizationData.id; - - if (newId && cartItemInfo) { - const cartResponse = await fetch( - `/api/cart/items/${cartItemInfo.cartItemId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - quantity: cartItemInfo.quantity, - customizationIdList: [newId], - }), - }, - ); - if (!cartResponse.ok) { - setState('error'); - setTimeout(() => setState('idle'), 3000); - return; - } - dispatchCartUpdated(); - } - } else if (guestMatch?.id) { - updateItemCustomization(guestMatch.id, { - text: draft.text, - color: draft.color, - size: draft.size, - imageUrl: draft.imageUrl, - imageUploadId: draft.imageUploadId, - designPosition: draft.designPosition ?? null, - }); - } - } catch { - setState('error'); - setTimeout(() => setState('idle'), 3000); - } finally { - setSavingDesign(false); - } - }, [ - isAuthenticated, - customization, - productId, - cartItemInfo, - guestMatch, - updateItemCustomization, - ]); - - const performAuthenticatedAdd = useCallback( - async (draft: CustomizationDraftPayload | null) => { - const validation = customizationDraftSchema.safeParse( - draft ?? normalizeCustomizationDraft(null), - ); - if (!validation.success) return false; - - let customizationIdList: string[] = []; - const hasCustomizationContent = - draft && - (hasText(draft.text) || - hasText(draft.color) || - hasText(draft.size) || - hasText(draft.imageUrl) || - Boolean(draft.designPosition)); - - if (hasCustomizationContent) { - const customizationResponse = await fetch( - '/api/customizations/customer', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - productId, - text: draft.text, - color: draft.color, - size: draft.size, - imageUrl: draft.imageUrl, - designPosition: draft.designPosition ?? null, - }), - }, - ); - if (!customizationResponse.ok) return false; - - const customizationData = (await customizationResponse.json()) as { - id?: string; - }; - if (customizationData.id) { - customizationIdList = [customizationData.id]; - } - } - - const res = await fetch('/api/cart/items', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ productId, quantity: 1, customizationIdList }), - }); - - return res.ok; - }, - [productId], - ); - - const performAdd = useCallback( - async (draft: CustomizationDraftPayload | null) => { - setState('adding'); - - let isOk = false; - try { - if (isAuthenticated) { - isOk = await performAuthenticatedAdd(draft); - } else { - const normalized = draft ?? normalizeCustomizationDraft(null); - const validation = customizationDraftSchema.safeParse(normalized); - if (validation.success) { - addItem({ - productId, - sellerId, - quantity: 1, - unitPriceSnapshot: price, - productName, - sellerName, - productImageUrl: imageUrl, - customizationText: normalized.text, - customizationColor: normalized.color, - customizationSize: normalized.size, - customizationImageUrl: normalized.imageUrl, - customizationImageUploadId: normalized.imageUploadId, - customizationDesignPosition: normalized.designPosition ?? null, - }); - isOk = true; - } - } - - if (!isOk) { - setState('error'); - setTimeout(() => setState('idle'), 3000); - return; - } - - setState('success'); - setTimeout(() => setState('idle'), 2000); - dispatchCartUpdated(); - refreshCartForProduct(); - } catch { - setState('error'); - setTimeout(() => setState('idle'), 3000); - } - }, - [ - isAuthenticated, - productId, - sellerId, - price, - productName, - sellerName, - imageUrl, - addItem, - refreshCartForProduct, - performAuthenticatedAdd, - ], - ); - - const handleAddAnother = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - if (savingDesign || state === 'adding' || disabled) return; - - await performAdd(normalizedCustomization); - }, - [disabled, normalizedCustomization, performAdd, savingDesign, state], - ); - - const handleAdd = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - if (state === 'adding' || disabled) return; - - if (customizationAvailable && !isCustomizationHasContent) { - setShowCustomizationChoice(true); - return; - } - - await performAdd(normalizedCustomization); - }, - [ - state, - disabled, - customizationAvailable, - isCustomizationHasContent, - normalizedCustomization, - performAdd, - ], - ); - - const handleAddWithoutCustomization = useCallback(async () => { - setShowCustomizationChoice(false); - await performAdd(null); - }, [performAdd]); - - const handleIncrement = useCallback(async () => { - if (savingDesign || !isInCart || currentQuantity >= MAX_QUANTITY) return; - const newQty = currentQuantity + 1; - if (isAuthenticated && cartItemInfo) { - const prevCartItemInfo = cartItemInfo; - setCartItemInfo({ ...cartItemInfo, quantity: newQty }); - try { - const res = await fetch(`/api/cart/items/${cartItemInfo.cartItemId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ quantity: newQty }), - }); - if (!res.ok) { - setCartItemInfo(prevCartItemInfo); - return; - } - dispatchCartUpdated(); - } catch { - setCartItemInfo(prevCartItemInfo); - } - } else if (guestMatch?.id) { - updateItemQuantity(guestMatch.id, newQty); - } - }, [ - isInCart, - currentQuantity, - isAuthenticated, - cartItemInfo, - guestMatch, - updateItemQuantity, - savingDesign, - ]); - - const handleDecrement = useCallback(async () => { - if (savingDesign || !isInCart) return; - if (currentQuantity <= 1) return; // use explicit remove button - - const newQty = currentQuantity - 1; - if (isAuthenticated && cartItemInfo) { - const prevCartItemInfo = cartItemInfo; - setCartItemInfo({ ...cartItemInfo, quantity: newQty }); - try { - const res = await fetch(`/api/cart/items/${cartItemInfo.cartItemId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ quantity: newQty }), - }); - if (!res.ok) { - setCartItemInfo(prevCartItemInfo); - return; - } - dispatchCartUpdated(); - } catch { - setCartItemInfo(prevCartItemInfo); - } - } else if (guestMatch?.id) { - updateItemQuantity(guestMatch.id, newQty); - } - }, [ - isInCart, - currentQuantity, - isAuthenticated, - cartItemInfo, - guestMatch, - updateItemQuantity, - savingDesign, - ]); - - const handleRemove = useCallback(async () => { - if (savingDesign) return; - if (isAuthenticated && cartItemInfo) { - const prev = cartItemInfo; - setCartItemInfo(null); - try { - const res = await fetch(`/api/cart/items/${cartItemInfo.cartItemId}`, { - method: 'DELETE', - }); - if (!res.ok) { - setCartItemInfo(prev); - return; - } - dispatchCartUpdated(); - } catch { - setCartItemInfo(prev); - } - } else if (guestMatch?.id) { - removeItemById(guestMatch.id); - } - }, [isAuthenticated, cartItemInfo, guestMatch, removeItemById, savingDesign]); - - const feedbackLabel = (() => { - if (state === 'adding') return labels.adding; - if (state === 'success') return labels.added; - if (state === 'error') return labels.error; - return labels.addToCart; - })(); - - // Success / Error feedback. - if (state === 'success' || state === 'error') { - return ( - - ); - } - - // In cart: quantity controls + save design + remove button. - if (isInCart) { - return ( -
-
- - - {currentQuantity} - - -
- {isCustomizationHasContent && labels.saveDesign && ( - - )} - {isCustomizationHasContent && labels.addAnotherPersonalization && ( - - )} - -
- ); - } - - // Product in cart with a different customization variant: show "Add another" - // button without quantity controls (this variant is not yet in the cart). - if (productInCart && isCustomizationHasContent && isAuthenticated) { - return ( -
- -
- ); - } +export function AddToCartButton(props: AddToCartButtonProps) { + const button = useAddToCartButton(props); - // Default: "Add to Cart". return ( - <> - setShowCustomizationChoice(false)} - /> -
- -
- + ); } diff --git a/modules/cart/presentation/components/add-to-cart-types.ts b/modules/cart/presentation/components/add-to-cart-types.ts new file mode 100644 index 00000000..c448dd04 --- /dev/null +++ b/modules/cart/presentation/components/add-to-cart-types.ts @@ -0,0 +1,42 @@ +import type { CustomizationDraftPayload } from './customization-draft-schema'; + +export interface CartButtonLabels { + addToCart: string; + removeFromCart: string; + adding: string; + added: string; + error: string; + customizeProduct?: string; + addWithoutCustomization?: string; + customizationChoiceBadge?: string; + customizationChoiceTitle?: string; + customizationChoiceMessage?: string; + decreaseQuantity?: string; + increaseQuantity?: string; + saveDesign?: string; + savingDesign?: string; + addAnotherPersonalization?: string; + alreadyInCart?: string; +} + +export interface AddToCartButtonProps { + productId: string; + productName: string; + sellerId: string; + sellerName: string; + price: number; + imageUrl?: string | null; + customization?: CustomizationDraftPayload | null; + customizationAvailable?: boolean; + customizeHref?: string; + disabled?: boolean; + editCartItemId?: string; + labels: CartButtonLabels; +} + +export interface CartItemInfo { + cartItemId: string; + quantity: number; +} + +export type ButtonState = 'idle' | 'adding' | 'success' | 'error'; diff --git a/modules/cart/presentation/components/cart-api.ts b/modules/cart/presentation/components/cart-api.ts new file mode 100644 index 00000000..3d88d279 --- /dev/null +++ b/modules/cart/presentation/components/cart-api.ts @@ -0,0 +1,62 @@ +import type { CustomizationDraftPayload } from './customization-draft-schema'; + +export interface CartApiItem { + id: string; + productId: string; + quantity: number; + customizations?: Array<{ + text?: string | null; + color?: string | null; + size?: string | null; + imageUrl?: string | null; + }>; +} + +export async function fetchAuthenticatedCart(): Promise { + const response = await fetch('/api/cart'); + if (!response.ok) return null; + const data = (await response.json()) as { items?: CartApiItem[] }; + return data.items ?? []; +} + +export async function addAuthenticatedCartItem( + productId: string, + customization: CustomizationDraftPayload | null, +): Promise { + const response = await fetch('/api/cart/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId, + quantity: 1, + customizationIdList: [], + ...(customization && { customization }), + }), + }); + return response.ok; +} + +export async function updateAuthenticatedCartItem( + cartItemId: string, + body: { + quantity: number; + customizationIdList?: string[]; + customization?: CustomizationDraftPayload; + }, +): Promise { + const response = await fetch(`/api/cart/items/${cartItemId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return response.ok; +} + +export async function removeAuthenticatedCartItem( + cartItemId: string, +): Promise { + const response = await fetch(`/api/cart/items/${cartItemId}`, { + method: 'DELETE', + }); + return response.ok; +} diff --git a/modules/cart/presentation/components/cart-item-matching.ts b/modules/cart/presentation/components/cart-item-matching.ts new file mode 100644 index 00000000..e79a261b --- /dev/null +++ b/modules/cart/presentation/components/cart-item-matching.ts @@ -0,0 +1,72 @@ +import { + normalizeCustomizationDraft, + type CustomizationDraftPayload, +} from './customization-draft-schema'; +import type { CartItemInfo } from './add-to-cart-types'; + +const hasText = (value?: string | null) => value != null && value.length > 0; + +interface CustomizationFields { + text?: string | null; + color?: string | null; + size?: string | null; + imageUrl?: string | null; +} + +interface CartItem extends CustomizationFields { + id: string; + productId: string; + quantity: number; + customizations?: CustomizationFields[]; +} + +export function hasCustomizationContent( + draft: CustomizationDraftPayload, +): boolean { + return ( + hasText(draft.text) || + hasText(draft.color) || + hasText(draft.size) || + hasText(draft.imageUrl) || + Boolean(draft.designPosition) + ); +} + +export function isCustomizationMatching( + fields: CustomizationFields, + draft: CustomizationDraftPayload | null, +): boolean { + const normalized = normalizeCustomizationDraft(draft); + return ( + (fields.text ?? null) === (normalized.text ?? null) && + (fields.color ?? null) === (normalized.color ?? null) && + (fields.size ?? null) === (normalized.size ?? null) && + (fields.imageUrl ?? null) === (normalized.imageUrl ?? null) + ); +} + +export function findCartItemInfo( + items: CartItem[], + productId: string, + customization: CustomizationDraftPayload, +): CartItemInfo | null { + const found = items.find( + (item) => + item.productId === productId && + ((item.customizations?.length ?? 0) === 0 + ? !hasCustomizationContent(customization) + : item.customizations?.some((fields) => + isCustomizationMatching(fields, customization), + )), + ); + + return found ? { cartItemId: found.id, quantity: found.quantity } : null; +} + +export function findCartItemById( + items: Array<{ id: string; quantity: number }>, + cartItemId: string, +): CartItemInfo | null { + const item = items.find((candidate) => candidate.id === cartItemId); + return item ? { cartItemId: item.id, quantity: item.quantity } : null; +} diff --git a/modules/cart/presentation/components/use-add-to-cart-button.ts b/modules/cart/presentation/components/use-add-to-cart-button.ts new file mode 100644 index 00000000..84129d9c --- /dev/null +++ b/modules/cart/presentation/components/use-add-to-cart-button.ts @@ -0,0 +1,173 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { dispatchCartUpdated } from '@/modules/cart/presentation/cart-events'; +import { useGuestCart } from '@/modules/cart/presentation/guest-cart-context'; +import { + customizationDraftSchema, + normalizeCustomizationDraft, +} from './customization-draft-schema'; +import { + hasCustomizationContent, + isCustomizationMatching, +} from './cart-item-matching'; +import type { AddToCartButtonProps, ButtonState } from './add-to-cart-types'; +import { useAuthenticatedCart } from './use-authenticated-cart'; +import { useAuthenticatedCartActions } from './use-authenticated-cart-actions'; +import { useGuestCartActions } from './use-guest-cart-actions'; + +const MAX_QUANTITY = 99; + +export function useAddToCartButton(props: AddToCartButtonProps) { + const { status } = useSession(); + const guestCart = useGuestCart(); + const isAuthenticated = status === 'authenticated'; + const [state, setState] = useState('idle'); + const [showCustomizationChoice, setShowCustomizationChoice] = useState(false); + const [savingDesign, setSavingDesign] = useState(false); + const normalizedCustomization = useMemo( + () => normalizeCustomizationDraft(props.customization ?? null), + [props.customization], + ); + const hasCustomization = hasCustomizationContent(normalizedCustomization); + const authenticatedCart = useAuthenticatedCart({ + isAuthenticated, + productId: props.productId, + editCartItemId: props.editCartItemId, + customization: normalizedCustomization, + }); + const guestMatch = isAuthenticated + ? undefined + : guestCart.items.find( + (item) => + item.productId === props.productId && + isCustomizationMatching( + { + text: item.customizationText, + color: item.customizationColor, + size: item.customizationSize, + imageUrl: item.customizationImageUrl, + }, + normalizedCustomization, + ), + ); + const fail = useCallback(() => { + setState('error'); + setTimeout(() => setState('idle'), 3000); + }, []); + const authenticatedActions = useAuthenticatedCartActions({ + productId: props.productId, + cartItemInfo: authenticatedCart.cartItemInfo, + setCartItemInfo: authenticatedCart.setCartItemInfo, + onFailure: fail, + }); + const guestActions = useGuestCartActions({ + cart: guestCart, + match: guestMatch, + props, + }); + const quantity = isAuthenticated + ? (authenticatedCart.cartItemInfo?.quantity ?? 0) + : (guestMatch?.quantity ?? 0); + + const performAdd = useCallback( + async (draft: typeof normalizedCustomization | null) => { + setState('adding'); + try { + const normalized = draft ?? normalizeCustomizationDraft(null); + if (!customizationDraftSchema.safeParse(normalized).success) + return fail(); + const added = isAuthenticated + ? await authenticatedActions.addItem(normalized) + : (guestActions.addItem(normalized), true); + if (!added) return fail(); + setState('success'); + setTimeout(() => setState('idle'), 2000); + dispatchCartUpdated(); + await authenticatedCart.refreshCart(); + } catch { + fail(); + } + }, + [ + authenticatedActions, + authenticatedCart, + fail, + guestActions, + isAuthenticated, + ], + ); + + const updateQuantity = useCallback( + async (nextQuantity: number) => { + if (savingDesign || nextQuantity < 1 || nextQuantity > MAX_QUANTITY) + return; + if (isAuthenticated) + await authenticatedActions.updateQuantity(nextQuantity); + else guestActions.updateQuantity(nextQuantity); + }, + [authenticatedActions, guestActions, isAuthenticated, savingDesign], + ); + + const onSaveDesign = useCallback(async () => { + if (!customizationDraftSchema.safeParse(normalizedCustomization).success) + return; + setSavingDesign(true); + try { + if (isAuthenticated) + await authenticatedActions.saveDesign(normalizedCustomization); + else guestActions.saveDesign(normalizedCustomization); + } finally { + setSavingDesign(false); + } + }, [ + authenticatedActions, + guestActions, + isAuthenticated, + normalizedCustomization, + ]); + + const onRemove = useCallback(async () => { + if (savingDesign) return; + if (isAuthenticated) await authenticatedActions.removeItem(); + else guestActions.removeItem(); + }, [authenticatedActions, guestActions, isAuthenticated, savingDesign]); + + return { + state, + quantity, + isInCart: quantity > 0, + productInCart: authenticatedCart.productInCart, + isAuthenticated, + hasCustomization, + showCustomizationChoice, + savingDesign, + disabled: props.disabled ?? false, + labels: props.labels, + customizationChoiceTitle: + props.labels.customizationChoiceTitle ?? + `${props.labels.customizeProduct ?? 'Customize'} ${props.productName}`, + onAdd: async (event: React.MouseEvent) => { + event.preventDefault(); + if (state === 'adding' || props.disabled) return; + if (props.customizationAvailable && !hasCustomization) { + setShowCustomizationChoice(true); + return; + } + await performAdd(normalizedCustomization); + }, + onAddAnother: async (event: React.MouseEvent) => { + event.preventDefault(); + if (savingDesign || state === 'adding' || props.disabled) return; + await performAdd(normalizedCustomization); + }, + onAddWithoutCustomization: async () => { + setShowCustomizationChoice(false); + await performAdd(null); + }, + onCloseChoice: () => setShowCustomizationChoice(false), + onIncrement: () => updateQuantity(quantity + 1), + onDecrement: () => updateQuantity(quantity - 1), + onRemove, + onSaveDesign, + }; +} diff --git a/modules/cart/presentation/components/use-authenticated-cart-actions.ts b/modules/cart/presentation/components/use-authenticated-cart-actions.ts new file mode 100644 index 00000000..fb0c9618 --- /dev/null +++ b/modules/cart/presentation/components/use-authenticated-cart-actions.ts @@ -0,0 +1,93 @@ +import { useCallback } from 'react'; +import { dispatchCartUpdated } from '@/modules/cart/presentation/cart-events'; +import { + addAuthenticatedCartItem, + removeAuthenticatedCartItem, + updateAuthenticatedCartItem, +} from './cart-api'; +import { hasCustomizationContent } from './cart-item-matching'; +import type { CartItemInfo } from './add-to-cart-types'; +import type { CustomizationDraftPayload } from './customization-draft-schema'; + +interface AuthenticatedCartActionsOptions { + productId: string; + cartItemInfo: CartItemInfo | null; + setCartItemInfo: React.Dispatch>; + onFailure: () => void; +} + +export function useAuthenticatedCartActions({ + productId, + cartItemInfo, + setCartItemInfo, + onFailure, +}: AuthenticatedCartActionsOptions) { + const addItem = useCallback( + async (draft: CustomizationDraftPayload) => { + return addAuthenticatedCartItem( + productId, + hasCustomizationContent(draft) ? draft : null, + ); + }, + [productId], + ); + + const updateQuantity = useCallback( + async (nextQuantity: number) => { + if (!cartItemInfo) return; + const previous = cartItemInfo; + setCartItemInfo({ ...cartItemInfo, quantity: nextQuantity }); + try { + if ( + await updateAuthenticatedCartItem(cartItemInfo.cartItemId, { + quantity: nextQuantity, + }) + ) { + dispatchCartUpdated(); + return; + } + } catch { + // Restore the optimistic quantity below. + } + setCartItemInfo(previous); + }, + [cartItemInfo, setCartItemInfo], + ); + + const saveDesign = useCallback( + async (draft: CustomizationDraftPayload) => { + if (!cartItemInfo) return; + try { + const updated = await updateAuthenticatedCartItem( + cartItemInfo.cartItemId, + { + quantity: cartItemInfo.quantity, + customization: draft, + }, + ); + if (updated) dispatchCartUpdated(); + else onFailure(); + } catch { + onFailure(); + } + }, + [cartItemInfo, onFailure], + ); + + const removeItem = useCallback(async () => { + if (!cartItemInfo) return; + const previous = cartItemInfo; + setCartItemInfo(null); + try { + if (await removeAuthenticatedCartItem(cartItemInfo.cartItemId)) { + dispatchCartUpdated(); + return; + } + } catch { + // Restore the optimistic removal below. + } + setCartItemInfo(previous); + }, [cartItemInfo, setCartItemInfo]); + + return { addItem, updateQuantity, saveDesign, removeItem }; +} diff --git a/modules/cart/presentation/components/use-authenticated-cart.ts b/modules/cart/presentation/components/use-authenticated-cart.ts new file mode 100644 index 00000000..4d65658e --- /dev/null +++ b/modules/cart/presentation/components/use-authenticated-cart.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from 'react'; +import { findCartItemById, findCartItemInfo } from './cart-item-matching'; +import { fetchAuthenticatedCart, type CartApiItem } from './cart-api'; +import type { CartItemInfo } from './add-to-cart-types'; +import type { CustomizationDraftPayload } from './customization-draft-schema'; + +interface AuthenticatedCartOptions { + isAuthenticated: boolean; + productId: string; + editCartItemId?: string; + customization: CustomizationDraftPayload; +} + +export function useAuthenticatedCart({ + isAuthenticated, + productId, + editCartItemId, + customization, +}: AuthenticatedCartOptions) { + const [cartItemInfo, setCartItemInfo] = useState(null); + const [productInCart, setProductInCart] = useState(false); + + const updateCart = useCallback( + (items: CartApiItem[]) => { + const found = editCartItemId + ? findCartItemById(items, editCartItemId) + : findCartItemInfo(items, productId, customization); + setCartItemInfo(found); + setProductInCart( + !editCartItemId && + found === null && + items.some((item) => item.productId === productId), + ); + }, + [customization, editCartItemId, productId], + ); + + const refreshCart = useCallback(async () => { + if (!isAuthenticated) return; + try { + const items = await fetchAuthenticatedCart(); + if (items) updateCart(items); + } catch { + // Keep the existing button state when the cart cannot be loaded. + } + }, [isAuthenticated, updateCart]); + + useEffect(() => { + if (!isAuthenticated) return; + let isCancelled = false; + async function loadCart() { + try { + const items = await fetchAuthenticatedCart(); + if (items && !isCancelled) updateCart(items); + } catch { + // Keep the add state when the initial cart request fails. + } + } + loadCart(); + return () => { + isCancelled = true; + }; + }, [isAuthenticated, updateCart]); + + return { cartItemInfo, productInCart, setCartItemInfo, refreshCart }; +} diff --git a/modules/cart/presentation/components/use-guest-cart-actions.ts b/modules/cart/presentation/components/use-guest-cart-actions.ts new file mode 100644 index 00000000..f691bf09 --- /dev/null +++ b/modules/cart/presentation/components/use-guest-cart-actions.ts @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import type { useGuestCart } from '@/modules/cart/presentation/guest-cart-context'; +import type { AddToCartButtonProps } from './add-to-cart-types'; +import type { CustomizationDraftPayload } from './customization-draft-schema'; + +interface GuestCartActionsOptions { + cart: ReturnType; + match: ReturnType['items'][number] | undefined; + props: AddToCartButtonProps; +} + +export function useGuestCartActions({ + cart, + match, + props, +}: GuestCartActionsOptions) { + const addItem = useCallback( + (draft: CustomizationDraftPayload) => { + cart.addItem({ + productId: props.productId, + sellerId: props.sellerId, + quantity: 1, + unitPriceSnapshot: props.price, + productName: props.productName, + sellerName: props.sellerName, + productImageUrl: props.imageUrl ?? null, + customizationText: draft.text, + customizationColor: draft.color, + customizationSize: draft.size, + customizationImageUrl: draft.imageUrl, + customizationImageUploadId: draft.imageUploadId, + customizationDesignPosition: draft.designPosition ?? null, + }); + }, + [cart, props], + ); + + const updateQuantity = useCallback( + (quantity: number) => { + if (match?.id) cart.updateItemQuantity(match.id, quantity); + }, + [cart, match], + ); + + const saveDesign = useCallback( + (draft: CustomizationDraftPayload) => { + if (!match?.id) return; + cart.updateItemCustomization(match.id, { + text: draft.text, + color: draft.color, + size: draft.size, + imageUrl: draft.imageUrl, + imageUploadId: draft.imageUploadId, + designPosition: draft.designPosition ?? null, + }); + }, + [cart, match], + ); + + const removeItem = useCallback(() => { + if (match?.id) cart.removeItemById(match.id); + }, [cart, match]); + + return { addItem, updateQuantity, saveDesign, removeItem }; +} diff --git a/modules/cart/presentation/schemas/cart-schemas.ts b/modules/cart/presentation/schemas/cart-schemas.ts index 155b615c..538e0b0f 100644 --- a/modules/cart/presentation/schemas/cart-schemas.ts +++ b/modules/cart/presentation/schemas/cart-schemas.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { checkoutEligibilitySchema } from './checkout-eligibility-schema'; +import { designPositionSchema } from '@/shared/validation/design-position-schema'; /** * Zod schemas for cart API request validation. @@ -11,6 +12,32 @@ import { checkoutEligibilitySchema } from './checkout-eligibility-schema'; // --- addItem --- +const customerCustomizationSchema = z.object({ + text: z.string().max(500).nullable().optional(), + color: z + .string() + .max(50) + .refine((value) => value.trim().length > 0, { + message: 'Customization color cannot be empty if provided', + }) + .nullable() + .optional(), + size: z + .string() + .max(50) + .refine((value) => value.trim().length > 0, { + message: 'Customization size cannot be empty if provided', + }) + .nullable() + .optional(), + imageUrl: z + .string() + .regex(/^https?:\/\/.+/, 'Image URL must start with http:// or https://') + .nullable() + .optional(), + designPosition: designPositionSchema.nullable().optional(), +}); + export const addItemSchema = z.object({ productId: z.string().min(1, 'Product ID is required'), quantity: z.coerce @@ -19,6 +46,7 @@ export const addItemSchema = z.object({ .min(1, 'Quantity must be at least 1') .max(99, 'Quantity must be at most 99'), customizationIdList: z.array(z.string().min(1)).optional().default([]), + customization: customerCustomizationSchema.optional(), }); export type AddItemInput = z.infer; @@ -32,6 +60,7 @@ export const updateQuantitySchema = z.object({ .min(1, 'Quantity must be at least 1') .max(99, 'Quantity must be at most 99'), customizationIdList: z.array(z.string()).optional(), + customization: customerCustomizationSchema.optional(), }); export type UpdateQuantityInput = z.infer; diff --git a/modules/customizations/application/create-customer-customization.ts b/modules/customizations/application/create-customer-customization.ts index 25782bb3..fac6ba3e 100644 --- a/modules/customizations/application/create-customer-customization.ts +++ b/modules/customizations/application/create-customer-customization.ts @@ -63,6 +63,7 @@ export class CreateCustomerCustomization { async execute( dto: CreateCustomerCustomizationDTO, ownerUserId: string, + tx?: unknown, ): Promise { if (!ownerUserId) { throw new ValidationError('Owner user id is required', 'Invalid user'); @@ -86,6 +87,6 @@ export class CreateCustomerCustomization { createdAt: new Date(), }; - return this.repo.save(entity); + return this.repo.save(entity, tx); } } diff --git a/modules/customizations/domain/customization-repository.ts b/modules/customizations/domain/customization-repository.ts index ed32b89b..d0f58454 100644 --- a/modules/customizations/domain/customization-repository.ts +++ b/modules/customizations/domain/customization-repository.ts @@ -7,7 +7,7 @@ import type { CustomizationEntity } from './entities/customization'; * lives in the adapter. */ export interface CustomizationRepository { - save(entity: CustomizationEntity): Promise; + save(entity: CustomizationEntity, tx?: unknown): Promise; findById(id: string): Promise; /** * Returns only the IDs that exist. Missing IDs are silently absent diff --git a/modules/customizations/infrastructure/cart-customer-customization-creator.ts b/modules/customizations/infrastructure/cart-customer-customization-creator.ts new file mode 100644 index 00000000..2421348e --- /dev/null +++ b/modules/customizations/infrastructure/cart-customer-customization-creator.ts @@ -0,0 +1,23 @@ +import type { CustomerCustomizationCreatePort } from '@/modules/cart/domain/customer-customization-create-port'; +import { CreateCustomerCustomization } from '../application/create-customer-customization'; +import type { CustomizationRepository } from '../domain/customization-repository'; +import type { ProductCapabilityPort } from '@/modules/products/domain/product-capability-port'; + +/** Bridges Cart's creation port to the Customizations application service. */ +export class CartCustomerCustomizationCreator implements CustomerCustomizationCreatePort { + constructor( + private readonly repository: CustomizationRepository, + private readonly productCapability: ProductCapabilityPort, + ) {} + + async create( + input: Parameters[0], + userId: string, + tx?: unknown, + ) { + return new CreateCustomerCustomization( + this.repository, + this.productCapability, + ).execute(input, userId, tx); + } +} diff --git a/modules/customizations/infrastructure/prisma-customization-repository.ts b/modules/customizations/infrastructure/prisma-customization-repository.ts index 5dfacc27..a45e1608 100644 --- a/modules/customizations/infrastructure/prisma-customization-repository.ts +++ b/modules/customizations/infrastructure/prisma-customization-repository.ts @@ -1,3 +1,4 @@ +import { PrismaClient } from '@prisma/client'; import { prisma } from '@/shared/infrastructure/prisma'; import type { CustomizationRepository } from '../domain/customization-repository'; import { coerceDesignPosition } from '@/shared/kernel/domain/value-objects/design-position'; @@ -35,8 +36,12 @@ export class PrismaCustomizationRepository implements CustomizationRepository { }; } - async save(entity: CustomizationEntity): Promise { - const result = await prisma.customization.upsert({ + async save( + entity: CustomizationEntity, + tx?: unknown, + ): Promise { + const client = (tx ?? prisma) as PrismaClient; + const result = await client.customization.upsert({ where: { id: entity.id }, create: { id: entity.id, diff --git a/tests/unit/app/[locale]/seller/products/product-form.test.tsx b/tests/unit/app/[locale]/seller/products/product-form.test.tsx index 913de12b..00cdcfe6 100644 --- a/tests/unit/app/[locale]/seller/products/product-form.test.tsx +++ b/tests/unit/app/[locale]/seller/products/product-form.test.tsx @@ -438,6 +438,50 @@ describe('ProductForm', () => { ).toBeGreaterThan(0); }); + it('warns before leaving after reordering product images in edit mode', () => { + render( + , + ); + + fireEvent.click(screen.getAllByRole('button', { name: 'Bajar' })[0]); + + const event = new Event('beforeunload', { cancelable: true }); + globalThis.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + it('includes translated tags and sizes in the edit-mode PATCH payload', async () => { fetchMock.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); diff --git a/tests/unit/components/cart/add-to-cart-button.test.tsx b/tests/unit/components/cart/add-to-cart-button.test.tsx index 84efb4da..74bc287a 100644 --- a/tests/unit/components/cart/add-to-cart-button.test.tsx +++ b/tests/unit/components/cart/add-to-cart-button.test.tsx @@ -314,14 +314,12 @@ describe('AddToCartButton', () => { ); }); - it('locks add-another while an authenticated customization and cart POST are pending', async () => { + it('sends one coordinated request when adding an authenticated customization', async () => { mockUseSession.mockReturnValue({ data: { user: { id: 'user-1', name: 'Test' } } as never, status: 'authenticated', update: vi.fn(), } as never); - const { promise: customizationResponse, resolve: resolveCustomization } = - Promise.withResolvers(); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -335,7 +333,7 @@ describe('AddToCartButton', () => { ], }), }); - mockFetch.mockReturnValueOnce(customizationResponse); + mockFetch.mockResolvedValueOnce({ ok: true }); render( { fireEvent.click(addAnother); expect(addAnother).toBeDisabled(); - expect(mockFetch).toHaveBeenCalledTimes(2); - - resolveCustomization({ - ok: true, - json: async () => ({ id: 'customization-2' }), - } as Response); - mockFetch.mockResolvedValueOnce({ ok: true }); await waitFor(() => expect(mockFetch).toHaveBeenCalledWith( '/api/cart/items', @@ -376,13 +367,23 @@ describe('AddToCartButton', () => { body: JSON.stringify({ productId: 'prod-1', quantity: 1, - customizationIdList: ['customization-2'], + customizationIdList: [], + customization: { + text: 'Edited design', + color: null, + size: null, + imageUrl: null, + imageUploadId: null, + designPosition: null, + }, }), }), ); - expect(mockFetch.mock.invocationCallOrder[1]).toBeLessThan( - mockFetch.mock.invocationCallOrder[2], - ); + expect( + mockFetch.mock.calls.filter( + ([url]) => url === '/api/customizations/customer', + ), + ).toHaveLength(0); }); it('locks conflicting cart actions and reports a failed authenticated save', async () => { @@ -392,8 +393,6 @@ describe('AddToCartButton', () => { update: vi.fn(), } as never); const dispatchSpy = vi.spyOn(globalThis, 'dispatchEvent'); - const { promise: patchResponse, resolve: resolvePatch } = - Promise.withResolvers(); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -407,11 +406,7 @@ describe('AddToCartButton', () => { ], }), }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'customization-2' }), - }); - mockFetch.mockReturnValueOnce(patchResponse); + mockFetch.mockResolvedValueOnce({ ok: false, status: 409 }); render( { ); fireEvent.click(screen.getByRole('button', { name: /save design/i })); + await waitFor(() => + expect(screen.getByText('Error')).toBeInTheDocument(), + ); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenLastCalledWith( + '/api/cart/items/cart-item-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + quantity: 2, + customization: { + text: 'Updated design', + color: null, + size: null, + imageUrl: null, + imageUploadId: null, + designPosition: null, + }, + }), + }), + ); + expect( + mockFetch.mock.calls.filter( + ([url]) => url === '/api/customizations/customer', + ), + ).toHaveLength(0); + expect(dispatchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'cart:updated' }), + ); + }); + + it('reports an error when creating the authenticated customization fails', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'user-1', name: 'Test' } } as never, + status: 'authenticated', + update: vi.fn(), + } as never); + const dispatchSpy = vi.spyOn(globalThis, 'dispatchEvent'); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: [ + { + id: 'cart-item-1', + productId: 'prod-1', + quantity: 1, + customizations: [{ text: 'Updated design' }], + }, + ], + }), + }); + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + + render( + , + ); + await waitFor(() => expect( screen.getByRole('button', { name: /save design/i }), - ).toBeDisabled(), + ).toBeEnabled(), ); - expect(screen.getByRole('button', { name: /remove/i })).toBeDisabled(); - expect( - screen.getByRole('button', { name: /increase quantity/i }), - ).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: /save design/i })); - resolvePatch({ ok: false, status: 409 } as Response); await waitFor(() => expect(screen.getByText('Error')).toBeInTheDocument(), ); @@ -580,6 +631,52 @@ describe('AddToCartButton', () => { }); }); + it('reports a failed customized add without a follow-up customization request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: [] }), + }); + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /add to cart/i })); + + await waitFor(() => + expect(screen.getByText('Error')).toBeInTheDocument(), + ); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenLastCalledWith( + '/api/cart/items', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + productId: 'prod-1', + quantity: 1, + customizationIdList: [], + customization: { + text: 'Atomic design', + color: null, + size: null, + imageUrl: null, + imageUploadId: null, + designPosition: null, + }, + }), + }), + ); + expect( + mockFetch.mock.calls.filter( + ([url]) => url === '/api/customizations/customer', + ), + ).toHaveLength(0); + }); + it('shows error feedback on network failure', async () => { // Cart fetch on mount (empty cart). mockFetch.mockResolvedValueOnce({ diff --git a/tests/unit/modules/cart/application/add-item-to-cart.test.ts b/tests/unit/modules/cart/application/add-item-to-cart.test.ts index 38f59e1c..2a365f79 100644 --- a/tests/unit/modules/cart/application/add-item-to-cart.test.ts +++ b/tests/unit/modules/cart/application/add-item-to-cart.test.ts @@ -16,6 +16,48 @@ import { InvalidCustomizationError, } from '@/modules/cart/domain/errors'; import type { CartEntity } from '@/modules/cart/domain/entities/cart'; +import type { + CustomerCustomizationCreatePort, + CustomerCustomizationInput, +} from '@/modules/cart/domain/customer-customization-create-port'; +import type { TransactionRunner } from '@/shared/kernel/transaction-runner'; + +class TransactionalCustomizationCreator implements CustomerCustomizationCreatePort { + readonly committed: CustomerCustomizationInput[] = []; + + async create( + input: CustomerCustomizationInput & { productId: string }, + _userId: string, + tx?: unknown, + ): Promise<{ id: string; productId: string }> { + ( + tx as { customizations: CustomerCustomizationInput[] } + ).customizations.push(input); + return { id: 'created-customization', productId: input.productId }; + } +} + +class RecordingTransactionRunner implements TransactionRunner { + constructor(private readonly creator: TransactionalCustomizationCreator) {} + + async run(work: (tx: unknown) => Promise): Promise { + const tx = { customizations: [] as CustomerCustomizationInput[] }; + const result = await work(tx); + this.creator.committed.push(...tx.customizations); + return result; + } +} + +class FailingCartRepository extends MemoryCartRepository { + failNextSave = false; + + override async save(cart: CartEntity, _tx?: unknown): Promise { + if (this.failNextSave) { + throw new Error('Cart save failed'); + } + return super.save(cart); + } +} /** * Tests for AddItemToCart use case (spec REQ-CART-011 / REQ-CART-001 / REQ-CART-002). @@ -100,6 +142,32 @@ describe('AddItemToCart', () => { ); }); + it('rolls back a newly created customization when saving the cart fails', async () => { + const failingCartRepo = new FailingCartRepository(); + failingCartRepo.failNextSave = true; + const creator = new TransactionalCustomizationCreator(); + const transactionalUseCase = new AddItemToCart( + failingCartRepo, + productRepo, + outboxRepo, + customizationLookup, + new RecordingTransactionRunner(creator), + creator, + ); + + await expect( + transactionalUseCase.execute({ + userId: 'u1', + productId: 'p1', + quantity: 1, + customization: { text: 'Atomic design' }, + }), + ).rejects.toThrow('Cart save failed'); + + expect(creator.committed).toEqual([]); + expect(await failingCartRepo.findActiveByUserId('u1')).toBeNull(); + }); + it('reuses the existing ACTIVE cart on subsequent adds (no second CartCreated)', async () => { await useCase.execute({ userId: 'u1', productId: 'p1', quantity: 1 }); await useCase.execute({ userId: 'u1', productId: 'p2', quantity: 1 }); diff --git a/tests/unit/modules/cart/application/update-cart-item.test.ts b/tests/unit/modules/cart/application/update-cart-item.test.ts index 7a50f5de..1cb53344 100644 --- a/tests/unit/modules/cart/application/update-cart-item.test.ts +++ b/tests/unit/modules/cart/application/update-cart-item.test.ts @@ -16,6 +16,48 @@ import { } from '@/modules/cart/domain/errors'; import type { CartEntity } from '@/modules/cart/domain/entities/cart'; import type { CartItemEntity } from '@/modules/cart/domain/entities/cart-item'; +import type { + CustomerCustomizationCreatePort, + CustomerCustomizationInput, +} from '@/modules/cart/domain/customer-customization-create-port'; +import type { TransactionRunner } from '@/shared/kernel/transaction-runner'; + +class TransactionalCustomizationCreator implements CustomerCustomizationCreatePort { + readonly committed: CustomerCustomizationInput[] = []; + + async create( + input: CustomerCustomizationInput & { productId: string }, + _userId: string, + tx?: unknown, + ): Promise<{ id: string; productId: string }> { + ( + tx as { customizations: CustomerCustomizationInput[] } + ).customizations.push(input); + return { id: 'created-customization', productId: input.productId }; + } +} + +class RecordingTransactionRunner implements TransactionRunner { + constructor(private readonly creator: TransactionalCustomizationCreator) {} + + async run(work: (tx: unknown) => Promise): Promise { + const tx = { customizations: [] as CustomerCustomizationInput[] }; + const result = await work(tx); + this.creator.committed.push(...tx.customizations); + return result; + } +} + +class FailingCartRepository extends MemoryCartRepository { + failNextSave = false; + + override async save(cart: CartEntity, _tx?: unknown): Promise { + if (this.failNextSave) { + throw new Error('Cart save failed'); + } + return super.save(cart); + } +} const makeItem = (overrides: Partial = {}): CartItemEntity => ({ id: 'i-default', @@ -97,6 +139,36 @@ describe('UpdateCartItemQuantity', () => { expect(payload.quantity).toBe(5); }); + it('rolls back a newly created customization when saving the cart item fails', async () => { + const failingCartRepo = new FailingCartRepository(); + await failingCartRepo.save( + makeCart({ + items: [makeItem({ id: 'i1', cartId: 'c1', quantity: 2 })], + }), + ); + failingCartRepo.failNextSave = true; + const creator = new TransactionalCustomizationCreator(); + const transactionalUseCase = new UpdateCartItemQuantity( + failingCartRepo, + outboxRepo, + new RecordingTransactionRunner(creator), + creator, + ); + + await expect( + transactionalUseCase.execute({ + userId: 'u1', + itemId: 'i1', + quantity: 2, + customization: { text: 'Atomic design' }, + }), + ).rejects.toThrow('Cart save failed'); + + expect(creator.committed).toEqual([]); + const cart = await failingCartRepo.findActiveByUserId('u1'); + expect(cart?.items[0]).toMatchObject({ customizationIdList: [] }); + }); + it('decreases quantity', async () => { const item = await useCase.execute({ userId: 'u1', From 5ec0351ca3f842c90b18c0264574cc7a8491e0e1 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 18 Jul 2026 11:13:56 +0200 Subject: [PATCH 2/3] fix tests --- tests/e2e/personalization-flow.spec.ts | 41 ++++++++++++++++---------- tests/unit/cart-edit.test.tsx | 13 ++++---- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/tests/e2e/personalization-flow.spec.ts b/tests/e2e/personalization-flow.spec.ts index 12a4255e..31763149 100644 --- a/tests/e2e/personalization-flow.spec.ts +++ b/tests/e2e/personalization-flow.spec.ts @@ -29,12 +29,20 @@ function waitForCartSync(page: Page) { ); } -function waitForCustomizationCreation(page: Page) { - return page.waitForResponse( - (response) => - response.url().endsWith('/api/customizations/customer') && - response.request().method() === 'POST', - ); +function waitForCustomizedCartAdd(page: Page, text: string) { + return page.waitForResponse((response) => { + if ( + !response.url().endsWith('/api/cart/items') || + response.request().method() !== 'POST' + ) { + return false; + } + + const body = response.request().postDataJSON() as { + customization?: { text?: string | null }; + }; + return body.customization?.text === text; + }); } test.describe('Personalization flow', () => { @@ -91,15 +99,14 @@ test.describe('Personalization flow', () => { const firstCartSync = waitForCartSync(customerPage); await designField.fill('First design'); await firstCartSync; - const firstCustomizationCreation = - waitForCustomizationCreation(customerPage); + const firstCartAdd = waitForCustomizedCartAdd(customerPage, 'First design'); await customerPage .getByRole('button', { name: 'Añadir al carrito' }) .click(); - const firstCustomizationResponse = await firstCustomizationCreation; + const firstCartAddResponse = await firstCartAdd; expect( - firstCustomizationResponse.status(), - await firstCustomizationResponse.text(), + firstCartAddResponse.status(), + await firstCartAddResponse.text(), ).toBe(201); await expect( @@ -109,15 +116,17 @@ test.describe('Personalization flow', () => { const secondCartSync = waitForCartSync(customerPage); await designField.fill('Second design'); await secondCartSync; - const secondCustomizationCreation = - waitForCustomizationCreation(customerPage); + const secondCartAdd = waitForCustomizedCartAdd( + customerPage, + 'Second design', + ); await customerPage .getByRole('button', { name: 'Añadir otra personalización' }) .click(); - const secondCustomizationResponse = await secondCustomizationCreation; + const secondCartAddResponse = await secondCartAdd; expect( - secondCustomizationResponse.status(), - await secondCustomizationResponse.text(), + secondCartAddResponse.status(), + await secondCartAddResponse.text(), ).toBe(201); await expect( customerPage.getByRole('button', { name: 'Añadido' }), diff --git a/tests/unit/cart-edit.test.tsx b/tests/unit/cart-edit.test.tsx index af18876d..1f001243 100644 --- a/tests/unit/cart-edit.test.tsx +++ b/tests/unit/cart-edit.test.tsx @@ -94,10 +94,6 @@ describe('cart edit flows', () => { ok: true, json: async () => ({ items: [{ id: 'cart-item-1', quantity: 2 }] }), }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: 'customization-2' }), - }); mockFetch.mockResolvedValueOnce({ ok: true }); render(); @@ -113,7 +109,14 @@ describe('cart edit flows', () => { method: 'PATCH', body: JSON.stringify({ quantity: 2, - customizationIdList: ['customization-2'], + customization: { + text: 'Updated text', + color: 'Blue', + size: null, + imageUrl: null, + imageUploadId: null, + designPosition: null, + }, }), }), ), From 692825ee1aac4ae360312da5ac57a8598f139d6d Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 18 Jul 2026 11:56:46 +0200 Subject: [PATCH 3/3] fix ci comments --- .../products/product-form-image-helpers.ts | 2 +- .../seller/products/product-form-payload.ts | 23 +- .../seller/products/product-form-types.ts | 10 +- .../seller/products/product-form-upload.ts | 9 +- .../seller/products/product-form-view.tsx | 10 + app/[locale]/seller/products/product-form.tsx | 24 +-- .../products/use-product-form-photos.ts | 30 ++- .../products/use-product-form-submission.ts | 11 +- .../seller/products/use-product-form.ts | 37 ++-- app/api/cart/items/[itemId]/route.ts | 1 + modules/cart/application/add-item-to-cart.ts | 21 +- modules/cart/application/update-cart-item.ts | 60 +++++- .../customer-customization-create-port.ts | 2 +- .../components/add-to-cart-button-view.tsx | 18 +- .../cart/presentation/components/cart-api.ts | 6 +- .../components/cart-item-matching.ts | 19 +- .../components/use-add-to-cart-button.ts | 34 +-- .../use-authenticated-cart-actions.ts | 15 +- .../components/use-authenticated-cart.ts | 66 ++++-- .../cart-customer-customization-creator.ts | 2 +- .../application/create-product-use-case.ts | 11 +- .../application/update-product-use-case.ts | 27 +-- .../product-customization-config.ts | 11 + .../product-form-image-helpers.test.ts | 27 +++ .../products/product-form-payload.test.ts | 99 +++++++++ .../products/product-form-upload.test.ts | 42 ++++ .../seller/products/product-form.test.tsx | 106 ++++++++++ .../cart/add-to-cart-button.test.tsx | 199 ++++++++++++++++++ .../cart/cart-item-matching.test.ts | 56 +++++ .../cart/application/add-item-to-cart.test.ts | 31 ++- .../cart/application/update-cart-item.test.ts | 67 +++++- .../product-customization-config.test.ts | 7 + 32 files changed, 950 insertions(+), 133 deletions(-) create mode 100644 tests/unit/app/[locale]/seller/products/product-form-image-helpers.test.ts create mode 100644 tests/unit/app/[locale]/seller/products/product-form-payload.test.ts create mode 100644 tests/unit/app/[locale]/seller/products/product-form-upload.test.ts create mode 100644 tests/unit/components/cart/cart-item-matching.test.ts diff --git a/app/[locale]/seller/products/product-form-image-helpers.ts b/app/[locale]/seller/products/product-form-image-helpers.ts index 79b4c06e..18876e95 100644 --- a/app/[locale]/seller/products/product-form-image-helpers.ts +++ b/app/[locale]/seller/products/product-form-image-helpers.ts @@ -44,7 +44,7 @@ function createPhotoDraft( url: seed.url, alt: normalizePhotoName(seed.alt ?? '', fallbackName), size: null, - purpose: seed.purpose ?? purpose, + purpose, mimeType: seed.mimeType ?? 'image/jpeg', posterUrl: seed.posterUrl ?? null, }; diff --git a/app/[locale]/seller/products/product-form-payload.ts b/app/[locale]/seller/products/product-form-payload.ts index cdcd56e0..285eaca9 100644 --- a/app/[locale]/seller/products/product-form-payload.ts +++ b/app/[locale]/seller/products/product-form-payload.ts @@ -1,4 +1,5 @@ import type { ZodError } from 'zod'; +import { ProductCustomizationConfig } from '@/modules/products/domain/value-objects/product-customization-config'; import { productFormSchema } from '@/modules/products/presentation/schemas/product-form-schema'; import { photoIdsFor } from './product-form-image-helpers'; import { cleanPhotoLabels } from './product-form-translation-helpers'; @@ -11,12 +12,10 @@ import type { export function buildPayload(locale: SupportedLocale, form: FormState) { const current = form.translations[locale]; const hasCustomizableBase = form.images.customizableBase.length > 0; - const effectiveMode = - hasCustomizableBase && form.customizationConfig?.mode === 'description' - ? 'text_photo' - : form.customizationConfig?.mode; const customizationConfig = form.customizationConfig - ? { ...form.customizationConfig, mode: effectiveMode } + ? ProductCustomizationConfig.fromJson(form.customizationConfig) + .withCustomizableBase(hasCustomizableBase) + .toJson() : undefined; const photoIds = photoIdsFor(form.images); const translations = Object.values(form.translations) @@ -89,8 +88,20 @@ export function mapErrors(error: ZodError): FormErrors { const errors: FormErrors = {}; for (const issue of error.issues) { const path = issue.path[0] as keyof FormErrors | undefined; - if (path !== undefined && !Object.hasOwn(errors, path)) { + if ( + path !== undefined && + [ + 'name', + 'description', + 'price', + 'images', + 'customizationConfig', + ].includes(path) && + !Object.hasOwn(errors, path) + ) { errors[path] = issue.message; + } else if (!errors.general) { + errors.general = issue.message; } } return errors; diff --git a/app/[locale]/seller/products/product-form-types.ts b/app/[locale]/seller/products/product-form-types.ts index ae0af1dc..e43d90b3 100644 --- a/app/[locale]/seller/products/product-form-types.ts +++ b/app/[locale]/seller/products/product-form-types.ts @@ -40,10 +40,11 @@ interface ProductFormImageBucketsSeed { export type ProductFormImageSeeds = ProductFormImageSeed[] | ProductFormImageBucketsSeed; -export interface ProductFormProps { +export type ProductFormModeProps = + { mode: 'create'; productId?: never } | { mode: 'edit'; productId: string }; + +export interface ProductFormBaseProps { locale: string; - mode: ProductFormMode; - productId?: string; initialValues: { price: number; name?: string; @@ -57,6 +58,8 @@ export interface ProductFormProps { categories?: CategoryOption[]; } +export type ProductFormProps = ProductFormBaseProps & ProductFormModeProps; + export interface ProductPhotoBucketsState { cover: ProductPhotoDraft | null; showcase: ProductPhotoDraft[]; @@ -75,6 +78,7 @@ export interface FormState { } export interface FormErrors { + general?: string; name?: string; description?: string; price?: string; diff --git a/app/[locale]/seller/products/product-form-upload.ts b/app/[locale]/seller/products/product-form-upload.ts index 659f19ea..c2b347f4 100644 --- a/app/[locale]/seller/products/product-form-upload.ts +++ b/app/[locale]/seller/products/product-form-upload.ts @@ -40,7 +40,14 @@ export async function uploadProductPhoto( headers: { 'content-type': file.type }, body: file, }); - if (!uploadResponse.ok) throw new Error('File storage failed'); + if (!uploadResponse.ok) { + try { + await fetch(`/api/uploads/${result.id}`, { method: 'DELETE' }); + } catch { + // A failed cleanup remains eligible for the pending-upload cleanup job. + } + throw new Error('File storage failed'); + } return { id: result.id, diff --git a/app/[locale]/seller/products/product-form-view.tsx b/app/[locale]/seller/products/product-form-view.tsx index e6193a07..8f134081 100644 --- a/app/[locale]/seller/products/product-form-view.tsx +++ b/app/[locale]/seller/products/product-form-view.tsx @@ -45,6 +45,11 @@ export function ProductFormView({ {controller.serverError}

) : null} + {errors.general ? ( +

+ {errors.general} +

+ ) : null} {controller.saved ? (

{controller.saved} @@ -91,6 +96,11 @@ export function ProductFormView({ }))} placeholder={labels.customization.editor.categoryPlaceholder} /> + {errors.customizationConfig ? ( +

+ {errors.customizationConfig} +

+ ) : null} {errors.images ? (

{errors.images} diff --git a/app/[locale]/seller/products/product-form.tsx b/app/[locale]/seller/products/product-form.tsx index 767de942..44f08fe2 100644 --- a/app/[locale]/seller/products/product-form.tsx +++ b/app/[locale]/seller/products/product-form.tsx @@ -4,28 +4,14 @@ import { ProductFormView } from './product-form-view'; import type { ProductFormProps } from './product-form-types'; import { useProductForm } from './use-product-form'; -export function ProductForm({ - locale, - mode, - productId, - initialValues, - labels, - categories = [], -}: ProductFormProps) { - const controller = useProductForm({ - locale, - mode, - productId, - initialValues, - labels, - categories, - }); +export function ProductForm(props: ProductFormProps) { + const controller = useProductForm(props); return ( ); diff --git a/app/[locale]/seller/products/use-product-form-photos.ts b/app/[locale]/seller/products/use-product-form-photos.ts index 416cd27d..e643578a 100644 --- a/app/[locale]/seller/products/use-product-form-photos.ts +++ b/app/[locale]/seller/products/use-product-form-photos.ts @@ -34,7 +34,7 @@ export function useProductFormPhotos({ try { const existingCount = bucket === 'cover' ? 0 : form.images[bucket].length; - const uploads = await Promise.all( + const results = await Promise.allSettled( files.map((file, index) => uploadProductPhoto( file, @@ -43,6 +43,19 @@ export function useProductFormPhotos({ ), ), ); + const uploads = results + .filter( + ( + result, + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === 'fulfilled', + ) + .map((result) => result.value); + const failures = results.filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected', + ); setForm((current) => { const images = bucket === 'cover' @@ -67,10 +80,17 @@ export function useProductFormPhotos({ : (current.selectedPhotoId ?? uploads[0]?.id ?? null), }; }); - } catch (error) { - setPhotoError( - error instanceof Error ? error.message : labels.gallery.uploadError, - ); + if (failures.length > 0) { + setPhotoError( + failures + .map((failure) => + failure.reason instanceof Error + ? failure.reason.message + : labels.gallery.uploadError, + ) + .join(' '), + ); + } } finally { setUploading(false); } diff --git a/app/[locale]/seller/products/use-product-form-submission.ts b/app/[locale]/seller/products/use-product-form-submission.ts index 08666d4b..5887c298 100644 --- a/app/[locale]/seller/products/use-product-form-submission.ts +++ b/app/[locale]/seller/products/use-product-form-submission.ts @@ -34,15 +34,16 @@ export function useProductFormSubmission({ setServerError, }: ProductFormSubmissionOptions) { const router = useRouter(); - const endpoint = - mode === 'create' ? '/api/products' : `/api/products/${productId}`; - const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); setServerError(null); setSaved(null); setErrors({}); + if (mode === 'edit' && !productId) { + setServerError(labels.error); + return; + } const missingLocale = findTranslationWithMissingName(form); if (missingLocale) { setForm((current) => ({ ...current, activeLocale: missingLocale })); @@ -60,6 +61,8 @@ export function useProductFormSubmission({ } setLoading(true); try { + const endpoint = + mode === 'create' ? '/api/products' : `/api/products/${productId}`; const response = await fetch(endpoint, { method: mode === 'create' ? 'POST' : 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -84,11 +87,11 @@ export function useProductFormSubmission({ } }, [ - endpoint, form, labels, locale, mode, + productId, router, setErrors, setForm, diff --git a/app/[locale]/seller/products/use-product-form.ts b/app/[locale]/seller/products/use-product-form.ts index e727dced..7a82c48a 100644 --- a/app/[locale]/seller/products/use-product-form.ts +++ b/app/[locale]/seller/products/use-product-form.ts @@ -1,5 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { useUnsavedChangesGuard } from '@/shared/hooks/use-unsaved-changes-guard'; +import { ProductCustomizationConfig } from '@/modules/products/domain/value-objects/product-customization-config'; import { cleanPhotoLabels, buildTranslationMap, @@ -78,7 +79,11 @@ export function useProductForm(props: ProductFormProps): ProductFormController { price: String(initialValues.price), activeLocale: initialLocale, translations, - customizationConfig: initialValues.customizationConfig, + customizationConfig: ProductCustomizationConfig.fromJson( + initialValues.customizationConfig, + ) + .withCustomizableBase(images.customizableBase.length > 0) + .toJson(), images, selectedPhotoId: images.cover?.id ?? @@ -93,24 +98,9 @@ export function useProductForm(props: ProductFormProps): ProductFormController { const [saved, setSaved] = useState(null); const [serverError, setServerError] = useState(null); const [photoError, setPhotoError] = useState(null); - const initialFormSnapshot = useMemo( - () => - JSON.stringify({ - price: String(initialValues.price), - translations: buildTranslationMap(initialLocale, initialValues), - customizationConfig: initialValues.customizationConfig, - images: normalizeInitialImages(labels, initialValues.images), - }), - [initialLocale, initialValues, labels], - ); + const [initialFormSnapshot] = useState(() => snapshotForm(form)); const unsavedGuard = useUnsavedChangesGuard( - mode === 'edit' && - JSON.stringify({ - price: form.price, - translations: form.translations, - customizationConfig: form.customizationConfig, - images: form.images, - }) !== initialFormSnapshot, + mode === 'edit' && snapshotForm(form) !== initialFormSnapshot, labels.unsavedChanges ?? { title: 'Unsaved changes', message: 'You have unsaved changes. Leave this page?', @@ -153,3 +143,12 @@ export function useProductForm(props: ProductFormProps): ProductFormController { handleSubmit, }; } + +function snapshotForm(form: FormState) { + return JSON.stringify({ + price: form.price, + translations: form.translations, + customizationConfig: form.customizationConfig, + images: form.images, + }); +} diff --git a/app/api/cart/items/[itemId]/route.ts b/app/api/cart/items/[itemId]/route.ts index d6d0b675..95a824f3 100644 --- a/app/api/cart/items/[itemId]/route.ts +++ b/app/api/cart/items/[itemId]/route.ts @@ -44,6 +44,7 @@ export const PATCH = requireRole('CUSTOMER')(async function PATCH( const updateCartItem = new UpdateCartItemQuantity( cartRepository, outboxRepository, + customizationLookup, transactionRunner, customizationCreator, ); diff --git a/modules/cart/application/add-item-to-cart.ts b/modules/cart/application/add-item-to-cart.ts index 169c6eee..22e2525a 100644 --- a/modules/cart/application/add-item-to-cart.ts +++ b/modules/cart/application/add-item-to-cart.ts @@ -101,6 +101,15 @@ export class AddItemToCart { } async execute(dto: AddItemToCartDTO): Promise { + if (dto.customization && !this.customizationCreator) { + throw new Error('Customer customization creator is not configured'); + } + if (dto.customization && !this.txRunner) { + throw new Error( + 'Transaction runner is required for customer customization', + ); + } + const run = (fn: (tx: unknown) => Promise) => this.txRunner ? this.txRunner.run(fn) : fn(undefined); @@ -121,7 +130,7 @@ export class AddItemToCart { // 3. Validate customizations (if any). Deduplicate first — duplicate // IDs would cause the length check in validateCustomizations to // falsely reject a valid list. - const customizationIdList = [...new Set(dto.customizationIdList)]; + let customizationIdList = [...new Set(dto.customizationIdList)]; if (customizationIdList.length > 0) { await this.validateCustomizations( customizationIdList, @@ -131,10 +140,10 @@ export class AddItemToCart { } if (dto.customization) { - if (!this.customizationCreator) { - throw new Error('Customer customization creator is not configured'); + if (!tx || typeof tx !== 'object') { + throw new Error('Transaction runner did not provide a transaction'); } - const customization = await this.customizationCreator.create( + const customization = await this.customizationCreator!.create( { productId: dto.productId, ...dto.customization }, dto.userId, tx, @@ -145,7 +154,9 @@ export class AddItemToCart { 'Customization is not available for this product', ); } - customizationIdList.push(customization.id); + customizationIdList = [ + ...new Set([...customizationIdList, customization.id]), + ]; } // 4. Find or create the ACTIVE cart for the user. Spec REQ-CART-001 diff --git a/modules/cart/application/update-cart-item.ts b/modules/cart/application/update-cart-item.ts index 29190cca..a71d30f6 100644 --- a/modules/cart/application/update-cart-item.ts +++ b/modules/cart/application/update-cart-item.ts @@ -4,11 +4,13 @@ import { Quantity } from '../domain/value-objects/quantity'; import type { OutboxRepository } from '@/shared/kernel/outbox-repository'; import { GlobalEvents } from '@/modules/events/domain/event-registry'; import type { CartItemEntity } from '../domain/entities/cart-item'; +import type { CustomizationLookupPort } from '../domain/customization-lookup-port'; import type { TransactionRunner } from '@/shared/kernel/transaction-runner'; import type { CustomerCustomizationCreatePort, CustomerCustomizationInput, } from '../domain/customer-customization-create-port'; +import { InvalidCustomizationError } from '../domain/errors'; // --- Data Transfer Object --- @@ -36,11 +38,43 @@ export class UpdateCartItemQuantity { constructor( private cartRepository: CartRepository, private outboxRepository: OutboxRepository, + private customizationLookup: CustomizationLookupPort, private txRunner?: TransactionRunner, private customizationCreator?: CustomerCustomizationCreatePort, ) {} + private async validateCustomizations( + customizationIdList: string[], + productId: string, + ): Promise { + const snapshots = + await this.customizationLookup.findByIds(customizationIdList); + + if (snapshots.length !== customizationIdList.length) { + throw new InvalidCustomizationError( + 'Some customization IDs do not exist', + 'One or more selected customizations are not available', + ); + } + + if (snapshots.some((snapshot) => snapshot.productId !== productId)) { + throw new InvalidCustomizationError( + `One or more customizations do not belong to product ${productId}`, + 'One or more selected customizations are not available for this product', + ); + } + } + async execute(dto: UpdateCartItemQuantityDTO): Promise { + if (dto.customization && !this.customizationCreator) { + throw new Error('Customer customization creator is not configured'); + } + if (dto.customization && !this.txRunner) { + throw new Error( + 'Transaction runner is required for customer customization', + ); + } + const run = (fn: (tx: unknown) => Promise) => this.txRunner ? this.txRunner.run(fn) : fn(undefined); @@ -54,22 +88,34 @@ export class UpdateCartItemQuantity { dto.itemId, ); - let customizationIdList = dto.customizationIdList; + let customizationIdList = dto.customizationIdList + ? [...new Set(dto.customizationIdList)] + : undefined; + if (customizationIdList) { + await this.validateCustomizations( + customizationIdList, + item.productId.value, + ); + } + if (dto.customization) { - if (!this.customizationCreator) { - throw new Error('Customer customization creator is not configured'); + if (!tx || typeof tx !== 'object') { + throw new Error('Transaction runner did not provide a transaction'); } - const customization = await this.customizationCreator.create( + const customization = await this.customizationCreator!.create( { productId: item.productId.value, ...dto.customization }, dto.userId, tx, ); if (customization.productId !== item.productId.value) { - throw new Error( - 'Created customization does not belong to the cart item product', + throw new InvalidCustomizationError( + `Customization ${customization.id} does not belong to product ${item.productId.value}`, + 'Customization is not available for this product', ); } - customizationIdList = [customization.id]; + customizationIdList = [ + ...new Set([...(customizationIdList ?? []), customization.id]), + ]; } // 6. Update the item, preserving the snapshot. diff --git a/modules/cart/domain/customer-customization-create-port.ts b/modules/cart/domain/customer-customization-create-port.ts index 15c09239..d52496af 100644 --- a/modules/cart/domain/customer-customization-create-port.ts +++ b/modules/cart/domain/customer-customization-create-port.ts @@ -16,6 +16,6 @@ export interface CustomerCustomizationCreatePort { create( input: CustomerCustomizationInput & { productId: string }, userId: string, - tx?: unknown, + tx: object, ): Promise<{ id: string; productId: string }>; } diff --git a/modules/cart/presentation/components/add-to-cart-button-view.tsx b/modules/cart/presentation/components/add-to-cart-button-view.tsx index 7b69dd61..48b61a80 100644 --- a/modules/cart/presentation/components/add-to-cart-button-view.tsx +++ b/modules/cart/presentation/components/add-to-cart-button-view.tsx @@ -36,6 +36,7 @@ function feedbackFor(state: ButtonState, labels: CartButtonLabels) { function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { const { labels, quantity, savingDesign, state } = props; + const controlsDisabled = props.disabled || savingDesign || state === 'adding'; return (

@@ -43,7 +44,7 @@ function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { type="button" className={styles.quantityButton} onClick={props.onDecrement} - disabled={savingDesign || quantity <= 1} + disabled={controlsDisabled || quantity <= 1} aria-label={labels.decreaseQuantity ?? 'Decrease quantity'} > − @@ -55,7 +56,7 @@ function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { type="button" className={styles.quantityButton} onClick={props.onIncrement} - disabled={savingDesign || quantity >= MAX_QUANTITY} + disabled={controlsDisabled || quantity >= MAX_QUANTITY} aria-label={labels.increaseQuantity ?? 'Increase quantity'} > + @@ -66,7 +67,7 @@ function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { type="button" className={styles.saveButton} onClick={props.onSaveDesign} - disabled={savingDesign || state === 'adding'} + disabled={controlsDisabled} > {savingDesign ? (labels.savingDesign ?? labels.saveDesign) @@ -78,7 +79,7 @@ function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { type="button" className={styles.saveButton} onClick={props.onAddAnother} - disabled={props.disabled || savingDesign || state === 'adding'} + disabled={controlsDisabled} > {state === 'adding' ? labels.adding @@ -89,7 +90,7 @@ function QuantityControls({ props }: { props: AddToCartButtonViewProps }) { type="button" className={styles.iconButton} onClick={props.onRemove} - disabled={savingDesign} + disabled={controlsDisabled} aria-label={labels.removeFromCart} >