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}
+
+ >
+ );
+}
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}
-
- >
- );
-}
-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}
+
+ = MAX_QUANTITY}
+ aria-label={labels.increaseQuantity ?? 'Increase quantity'}
+ >
+ +
+
+
+ {props.hasCustomization && labels.saveDesign ? (
+
+ {savingDesign
+ ? (labels.savingDesign ?? labels.saveDesign)
+ : labels.saveDesign}
+
+ ) : null}
+ {props.hasCustomization && labels.addAnotherPersonalization ? (
+
+ {state === 'adding'
+ ? labels.adding
+ : 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 (
+
+ {feedback}
+
+ );
+ }
+ if (props.isInCart) return ;
+ if (props.productInCart && props.hasCustomization && props.isAuthenticated) {
+ return (
+
+
+ {props.state === 'adding'
+ ? props.labels.adding
+ : props.labels.addAnotherPersonalization}
+
+
+ );
+ }
+ 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 (
-
- {feedbackLabel}
-
- );
- }
-
- // In cart: quantity controls + save design + remove button.
- if (isInCart) {
- return (
-
-
-
- −
-
-
- {currentQuantity}
-
- = MAX_QUANTITY}
- aria-label={increaseQuantityLabel}
- >
- +
-
-
- {isCustomizationHasContent && labels.saveDesign && (
-
- {savingDesign
- ? (labels.savingDesign ?? labels.saveDesign)
- : labels.saveDesign}
-
- )}
- {isCustomizationHasContent && labels.addAnotherPersonalization && (
-
- {state === 'adding'
- ? labels.adding
- : 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 (
-
-
- {state === 'adding'
- ? labels.adding
- : labels.addAnotherPersonalization}
-
-
- );
- }
+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}
>
@@ -153,7 +154,12 @@ export function AddToCartButtonView(props: AddToCartButtonViewProps) {
);
}
if (props.isInCart) return ;
- if (props.productInCart && props.hasCustomization && props.isAuthenticated) {
+ if (
+ props.productInCart &&
+ props.hasCustomization &&
+ props.isAuthenticated &&
+ props.labels.addAnotherPersonalization
+ ) {
return (
;
}
diff --git a/modules/cart/presentation/components/cart-item-matching.ts b/modules/cart/presentation/components/cart-item-matching.ts
index e79a261b..deecab67 100644
--- a/modules/cart/presentation/components/cart-item-matching.ts
+++ b/modules/cart/presentation/components/cart-item-matching.ts
@@ -11,6 +11,7 @@ interface CustomizationFields {
color?: string | null;
size?: string | null;
imageUrl?: string | null;
+ designPosition?: Record | null;
}
interface CartItem extends CustomizationFields {
@@ -41,10 +42,26 @@ export function isCustomizationMatching(
(fields.text ?? null) === (normalized.text ?? null) &&
(fields.color ?? null) === (normalized.color ?? null) &&
(fields.size ?? null) === (normalized.size ?? null) &&
- (fields.imageUrl ?? null) === (normalized.imageUrl ?? null)
+ (fields.imageUrl ?? null) === (normalized.imageUrl ?? null) &&
+ stableSerialize(fields.designPosition ?? null) ===
+ stableSerialize(normalized.designPosition ?? null)
);
}
+function stableSerialize(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map((entry) => stableSerialize(entry)).join(',')}]`;
+ }
+ if (value && typeof value === 'object') {
+ const record = value as Record;
+ return `{${Object.keys(record)
+ .toSorted((left, right) => left.localeCompare(right))
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`)
+ .join(',')}}`;
+ }
+ return JSON.stringify(value);
+}
+
export function findCartItemInfo(
items: CartItem[],
productId: string,
diff --git a/modules/cart/presentation/components/use-add-to-cart-button.ts b/modules/cart/presentation/components/use-add-to-cart-button.ts
index 84129d9c..9a9bb9d7 100644
--- a/modules/cart/presentation/components/use-add-to-cart-button.ts
+++ b/modules/cart/presentation/components/use-add-to-cart-button.ts
@@ -35,21 +35,25 @@ export function useAddToCartButton(props: AddToCartButtonProps) {
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,
- ),
- );
+ let guestMatch: (typeof guestCart.items)[number] | undefined;
+ if (!isAuthenticated) {
+ guestMatch = props.editCartItemId
+ ? guestCart.items.find((item) => item.id === props.editCartItemId)
+ : guestCart.items.find(
+ (item) =>
+ item.productId === props.productId &&
+ isCustomizationMatching(
+ {
+ text: item.customizationText,
+ color: item.customizationColor,
+ size: item.customizationSize,
+ imageUrl: item.customizationImageUrl,
+ designPosition: item.customizationDesignPosition,
+ },
+ normalizedCustomization,
+ ),
+ );
+ }
const fail = useCallback(() => {
setState('error');
setTimeout(() => setState('idle'), 3000);
diff --git a/modules/cart/presentation/components/use-authenticated-cart-actions.ts b/modules/cart/presentation/components/use-authenticated-cart-actions.ts
index fb0c9618..a1523038 100644
--- a/modules/cart/presentation/components/use-authenticated-cart-actions.ts
+++ b/modules/cart/presentation/components/use-authenticated-cart-actions.ts
@@ -49,9 +49,15 @@ export function useAuthenticatedCartActions({
} catch {
// Restore the optimistic quantity below.
}
- setCartItemInfo(previous);
+ setCartItemInfo((current) =>
+ current?.cartItemId === previous.cartItemId &&
+ current.quantity === nextQuantity
+ ? previous
+ : current,
+ );
+ onFailure();
},
- [cartItemInfo, setCartItemInfo],
+ [cartItemInfo, onFailure, setCartItemInfo],
);
const saveDesign = useCallback(
@@ -86,8 +92,9 @@ export function useAuthenticatedCartActions({
} catch {
// Restore the optimistic removal below.
}
- setCartItemInfo(previous);
- }, [cartItemInfo, setCartItemInfo]);
+ setCartItemInfo((current) => (current === null ? previous : current));
+ onFailure();
+ }, [cartItemInfo, onFailure, 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
index 4d65658e..21d92a4a 100644
--- a/modules/cart/presentation/components/use-authenticated-cart.ts
+++ b/modules/cart/presentation/components/use-authenticated-cart.ts
@@ -1,4 +1,11 @@
-import { useCallback, useEffect, useState } from 'react';
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type Dispatch,
+ type SetStateAction,
+} from 'react';
import { findCartItemById, findCartItemInfo } from './cart-item-matching';
import { fetchAuthenticatedCart, type CartApiItem } from './cart-api';
import type { CartItemInfo } from './add-to-cart-types';
@@ -17,22 +24,51 @@ export function useAuthenticatedCart({
editCartItemId,
customization,
}: AuthenticatedCartOptions) {
- const [cartItemInfo, setCartItemInfo] = useState(null);
- const [productInCart, setProductInCart] = useState(false);
+ const [items, setItems] = useState([]);
+ const [optimisticItem, setOptimisticItem] = useState<{
+ key: string;
+ item: CartItemInfo | null;
+ } | null>(null);
+ const matchKey = JSON.stringify({
+ isAuthenticated,
+ productId,
+ editCartItemId,
+ customization,
+ });
+ const matchedItem = useMemo(() => {
+ if (!isAuthenticated) return null;
+ return editCartItemId
+ ? findCartItemById(items, editCartItemId)
+ : findCartItemInfo(items, productId, customization);
+ }, [customization, editCartItemId, isAuthenticated, items, productId]);
+ const cartItemInfo =
+ optimisticItem?.key === matchKey ? optimisticItem.item : matchedItem;
+ const productInCart =
+ !editCartItemId &&
+ cartItemInfo === null &&
+ isAuthenticated &&
+ items.some((item) => item.productId === productId);
- 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),
- );
+ const updateCart = useCallback((nextItems: CartApiItem[]) => {
+ setItems(nextItems);
+ setOptimisticItem(null);
+ }, []);
+
+ const setCartItemInfo = useCallback<
+ Dispatch>
+ >(
+ (nextItem) => {
+ setOptimisticItem((current) => {
+ const currentItem =
+ current?.key === matchKey ? current.item : matchedItem;
+ return {
+ key: matchKey,
+ item:
+ typeof nextItem === 'function' ? nextItem(currentItem) : nextItem,
+ };
+ });
},
- [customization, editCartItemId, productId],
+ [matchKey, matchedItem],
);
const refreshCart = useCallback(async () => {
diff --git a/modules/customizations/infrastructure/cart-customer-customization-creator.ts b/modules/customizations/infrastructure/cart-customer-customization-creator.ts
index 2421348e..c04ccb9b 100644
--- a/modules/customizations/infrastructure/cart-customer-customization-creator.ts
+++ b/modules/customizations/infrastructure/cart-customer-customization-creator.ts
@@ -13,7 +13,7 @@ export class CartCustomerCustomizationCreator implements CustomerCustomizationCr
async create(
input: Parameters[0],
userId: string,
- tx?: unknown,
+ tx: object,
) {
return new CreateCustomerCustomization(
this.repository,
diff --git a/modules/products/application/create-product-use-case.ts b/modules/products/application/create-product-use-case.ts
index a9a5a74e..79e756e7 100644
--- a/modules/products/application/create-product-use-case.ts
+++ b/modules/products/application/create-product-use-case.ts
@@ -108,8 +108,14 @@ export class CreateProductUseCase {
const price = ProductPrice.create(dto.price, 'EUR' as Currency);
const now = new Date();
const productId = randomUUID();
+ const images = buildProductImages(dto.images ?? [], {
+ productId,
+ createdAt: now,
+ });
const customizationConfig = ProductCustomizationConfig.fromJson(
dto.customizationConfig ?? null,
+ ).withCustomizableBase(
+ images.some((image) => image.purpose === 'CUSTOMIZABLE_BASE'),
);
const product: ProductEntity = {
id: productId,
@@ -123,10 +129,7 @@ export class CreateProductUseCase {
createdAt: now,
updatedAt: now,
translations,
- images: buildProductImages(dto.images ?? [], {
- productId,
- createdAt: now,
- }),
+ images,
tags: [],
};
diff --git a/modules/products/application/update-product-use-case.ts b/modules/products/application/update-product-use-case.ts
index 87241a64..4785c25d 100644
--- a/modules/products/application/update-product-use-case.ts
+++ b/modules/products/application/update-product-use-case.ts
@@ -197,13 +197,23 @@ export class UpdateProductUseCase {
throw new ValidationError('At least one field must be provided');
}
+ const now = new Date();
+ const images =
+ dto.images === undefined
+ ? product.images
+ : buildProductImages(dto.images, {
+ productId: product.id,
+ createdAt: now,
+ existingImages: product.images,
+ });
const translations = buildTranslations(dto, product);
- const nextCustomizationConfig =
+ const nextCustomizationConfig = (
dto.customizationConfig === undefined
? product.customizationConfig
- : ProductCustomizationConfig.fromJson(dto.customizationConfig);
-
- const now = new Date();
+ : ProductCustomizationConfig.fromJson(dto.customizationConfig)
+ )?.withCustomizableBase(
+ images.some((image) => image.purpose === 'CUSTOMIZABLE_BASE'),
+ );
const updated: ProductEntity = {
...product,
@@ -214,14 +224,7 @@ export class UpdateProductUseCase {
? product.categoryId
: (nextCustomizationConfig?.categoryId ?? null),
customizationConfig: nextCustomizationConfig,
- images:
- dto.images === undefined
- ? product.images
- : buildProductImages(dto.images, {
- productId: product.id,
- createdAt: now,
- existingImages: product.images,
- }),
+ images,
updatedAt: now,
translations,
};
diff --git a/modules/products/domain/value-objects/product-customization-config.ts b/modules/products/domain/value-objects/product-customization-config.ts
index 689665dc..1c7905f9 100644
--- a/modules/products/domain/value-objects/product-customization-config.ts
+++ b/modules/products/domain/value-objects/product-customization-config.ts
@@ -155,6 +155,17 @@ export class ProductCustomizationConfig {
return this.mode === 'photo' || this.mode === 'text_photo';
}
+ withCustomizableBase(
+ hasCustomizableBase: boolean,
+ ): ProductCustomizationConfig {
+ if (!hasCustomizableBase || this.mode !== 'description') return this;
+
+ return new ProductCustomizationConfig({
+ ...this.toJson(),
+ mode: 'text_photo',
+ });
+ }
+
allowsStyleOptions(): boolean {
return this.mode === 'text' || this.mode === 'text_photo';
}
diff --git a/tests/unit/app/[locale]/seller/products/product-form-image-helpers.test.ts b/tests/unit/app/[locale]/seller/products/product-form-image-helpers.test.ts
new file mode 100644
index 00000000..8e4b46d3
--- /dev/null
+++ b/tests/unit/app/[locale]/seller/products/product-form-image-helpers.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose';
+import { normalizeInitialImages } from '@/app/[locale]/seller/products/product-form-image-helpers';
+
+describe('normalizeInitialImages', () => {
+ it('uses the bucket purpose instead of conflicting seed metadata', () => {
+ const images = normalizeInitialImages(
+ { gallery: { defaultPhotoName: 'Photo' } } as never,
+ {
+ cover: null,
+ showcase: [],
+ customizableBase: [
+ {
+ id: 'base-1',
+ url: 'https://cdn.example.com/base.png',
+ alt: 'Base',
+ purpose: ProductImagePurpose.SHOWCASE,
+ },
+ ],
+ },
+ );
+
+ expect(images.customizableBase[0].purpose).toBe(
+ ProductImagePurpose.CUSTOMIZABLE_BASE,
+ );
+ });
+});
diff --git a/tests/unit/app/[locale]/seller/products/product-form-payload.test.ts b/tests/unit/app/[locale]/seller/products/product-form-payload.test.ts
new file mode 100644
index 00000000..d22d9783
--- /dev/null
+++ b/tests/unit/app/[locale]/seller/products/product-form-payload.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+import { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose';
+import { productFormSchema } from '@/modules/products/presentation/schemas/product-form-schema';
+import {
+ buildPayload,
+ mapErrors,
+} from '@/app/[locale]/seller/products/product-form-payload';
+import type { FormState } from '@/app/[locale]/seller/products/product-form-types';
+
+function createForm(): FormState {
+ return {
+ price: '19.99',
+ activeLocale: 'es',
+ translations: {
+ es: {
+ locale: 'es',
+ name: 'Taza',
+ description: '',
+ tags: [],
+ sizes: [],
+ designChangeDescription: null,
+ photoLabels: {},
+ },
+ cat: {
+ locale: 'cat',
+ name: '',
+ description: '',
+ tags: [],
+ sizes: [],
+ designChangeDescription: null,
+ photoLabels: {},
+ },
+ },
+ customizationConfig: {
+ mode: 'description',
+ previewEnabled: false,
+ previewTemplateUrl: null,
+ textOffset: null,
+ imageOffset: null,
+ },
+ images: {
+ cover: null,
+ showcase: [],
+ customizableBase: [
+ {
+ id: 'base-1',
+ url: 'https://cdn.example.com/base.png',
+ alt: 'Base',
+ purpose: ProductImagePurpose.CUSTOMIZABLE_BASE,
+ mimeType: 'image/png',
+ posterUrl: null,
+ },
+ ],
+ },
+ selectedPhotoId: 'base-1',
+ };
+}
+
+describe('product form payload', () => {
+ it('infers text_photo from the domain config when a customizable base exists', () => {
+ const result = buildPayload('es', createForm());
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.payload.customizationConfig?.mode).toBe('text_photo');
+ }
+ });
+
+ it('maps nested customization errors and falls back to general for unknown and translation paths', () => {
+ const customization = productFormSchema.safeParse({
+ price: 19.99,
+ customizationConfig: {
+ mode: 'invalid',
+ previewEnabled: false,
+ previewTemplateUrl: null,
+ textOffset: null,
+ imageOffset: null,
+ },
+ });
+ const unknown = productFormSchema.safeParse({ price: 19.99, legacy: true });
+ const translation = productFormSchema.safeParse({
+ price: 19.99,
+ translations: [{ locale: 'es', name: '' }],
+ });
+
+ expect(customization.success).toBe(false);
+ expect(unknown.success).toBe(false);
+ expect(translation.success).toBe(false);
+ if (!customization.success) {
+ expect(mapErrors(customization.error).customizationConfig).toBeTruthy();
+ }
+ if (!unknown.success) {
+ expect(mapErrors(unknown.error).general).toBeTruthy();
+ }
+ if (!translation.success) {
+ expect(mapErrors(translation.error).general).toBeTruthy();
+ }
+ });
+});
diff --git a/tests/unit/app/[locale]/seller/products/product-form-upload.test.ts b/tests/unit/app/[locale]/seller/products/product-form-upload.test.ts
new file mode 100644
index 00000000..eb61ce0c
--- /dev/null
+++ b/tests/unit/app/[locale]/seller/products/product-form-upload.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose';
+import { uploadProductPhoto } from '@/app/[locale]/seller/products/product-form-upload';
+
+describe('uploadProductPhoto', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('compensates a failed storage upload by deleting its pending upload', async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === '/api/uploads/presigned-url') {
+ return Response.json(
+ {
+ id: 'upload-1',
+ uploadUrl: 'https://uploads.example.com/upload-1',
+ publicUrl: 'https://cdn.example.com/upload-1',
+ },
+ { status: 201 },
+ );
+ }
+ if (url === 'https://uploads.example.com/upload-1') {
+ return new Response(null, { status: 500 });
+ }
+ return Response.json({ success: true });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(
+ uploadProductPhoto(
+ new File(['photo'], 'photo.png', { type: 'image/png' }),
+ 'Photo 1',
+ ProductImagePurpose.SHOWCASE,
+ ),
+ ).rejects.toThrow('File storage failed');
+
+ expect(fetchMock).toHaveBeenCalledWith('/api/uploads/upload-1', {
+ method: 'DELETE',
+ });
+ });
+});
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 00cdcfe6..e02e4ce6 100644
--- a/tests/unit/app/[locale]/seller/products/product-form.test.tsx
+++ b/tests/unit/app/[locale]/seller/products/product-form.test.tsx
@@ -136,6 +136,7 @@ describe('ProductForm', () => {
{
expect(event.defaultPrevented).toBe(true);
});
+ it('does not mark normalized initial images dirty before they change', () => {
+ render(
+ ,
+ );
+
+ const event = new Event('beforeunload', { cancelable: true });
+ globalThis.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
it('includes translated tags and sizes in the edit-mode PATCH payload', async () => {
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
@@ -796,4 +833,73 @@ describe('ProductForm', () => {
expect(img.getAttribute('src')).toContain('/products/photo.png');
});
});
+
+ it('keeps successfully uploaded photos when another upload fails', async () => {
+ fetchMock.mockImplementation(async (input: RequestInfo | URL, init) => {
+ const url = String(input);
+
+ if (url.includes('/api/uploads/presigned-url')) {
+ const body = JSON.parse(String(init?.body ?? '{}')) as {
+ fileName?: string;
+ };
+ if (body.fileName === 'failed.png') {
+ return Response.json({ error: 'Upload rejected' }, { status: 400 });
+ }
+ return Response.json(
+ {
+ id: 'successful-upload',
+ uploadUrl: 'https://uploads.example.com/successful.png',
+ publicUrl: 'http://localhost:8081/products/successful.png',
+ },
+ { status: 201 },
+ );
+ }
+
+ if (url === 'https://uploads.example.com/successful.png') {
+ return new Response(null, { status: 200 });
+ }
+
+ return new Response(null, { status: 200 });
+ });
+
+ render(
+ ,
+ );
+
+ fireEvent.change(
+ screen.getByLabelText(labels.gallery.buckets.showcase.addPhotoLabel),
+ {
+ target: {
+ files: [
+ new File(['ok'], 'successful.png', { type: 'image/png' }),
+ new File(['no'], 'failed.png', { type: 'image/png' }),
+ ],
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('img', { name: /successful/i })).toBeTruthy();
+ expect(screen.getByText('Upload rejected')).toBeInTheDocument();
+ });
+ });
});
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 74bc287a..3cb05974 100644
--- a/tests/unit/components/cart/add-to-cart-button.test.tsx
+++ b/tests/unit/components/cart/add-to-cart-button.test.tsx
@@ -837,6 +837,84 @@ describe('AddToCartButton', () => {
});
expect(plusBtn).toBeDisabled();
});
+
+ it('prioritizes editCartItemId over customization matching for a guest line', () => {
+ mockUseGuestCart.mockReturnValue({
+ items: [
+ {
+ id: 'guest-item-being-edited',
+ productId: 'prod-1',
+ sellerId: 'seller-1',
+ quantity: 4,
+ unitPriceSnapshot: 29.99,
+ customizationText: 'Original design',
+ },
+ ],
+ itemCount: 4,
+ addItem: mockAddItem,
+ updateQuantity: mockUpdateQuantity,
+ removeItem: mockRemoveItem,
+ updateItemQuantity: mockUpdateQuantity,
+ removeItemById: mockRemoveItem,
+ updateCustomization: vi.fn(),
+ updateItemCustomization: vi.fn(),
+ clearCart: vi.fn(),
+ hydrated: true,
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('4')).toBeInTheDocument();
+ });
+
+ it('disables every cart action when the button is disabled', () => {
+ mockUseGuestCart.mockReturnValue({
+ items: [
+ {
+ id: 'guest-item-1',
+ productId: 'prod-1',
+ sellerId: 'seller-1',
+ quantity: 3,
+ unitPriceSnapshot: 29.99,
+ customizationText: 'Design',
+ },
+ ],
+ itemCount: 3,
+ addItem: mockAddItem,
+ updateQuantity: mockUpdateQuantity,
+ removeItem: mockRemoveItem,
+ updateItemQuantity: mockUpdateQuantity,
+ removeItemById: mockRemoveItem,
+ updateCustomization: vi.fn(),
+ updateItemCustomization: vi.fn(),
+ clearCart: vi.fn(),
+ hydrated: true,
+ });
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: /decrease quantity/i }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: /increase quantity/i }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: /save design/i }),
+ ).toBeDisabled();
+ expect(screen.getByRole('button', { name: /remove/i })).toBeDisabled();
+ });
});
describe('quantity controls — authenticated user', () => {
@@ -1006,5 +1084,126 @@ describe('AddToCartButton', () => {
).toBeTruthy();
});
});
+
+ it('does not render the authenticated add-another action without a localized label', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ items: [
+ {
+ id: 'cart-item-1',
+ productId: 'prod-1',
+ quantity: 1,
+ customizations: [{ text: 'Existing design' }],
+ },
+ ],
+ }),
+ });
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('button', { name: /add another/i }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('derives a new match from the loaded cart without another GET request', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ items: [
+ {
+ id: 'cart-item-a',
+ productId: 'prod-1',
+ quantity: 1,
+ customizations: [{ text: 'First design' }],
+ },
+ {
+ id: 'cart-item-b',
+ productId: 'prod-1',
+ quantity: 2,
+ customizations: [{ text: 'Second design' }],
+ },
+ ],
+ }),
+ });
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument());
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText('2')).toBeInTheDocument();
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a later optimistic quantity when an earlier request fails', async () => {
+ const { promise: firstUpdate, reject: rejectFirstUpdate } =
+ Promise.withResolvers();
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ items: [{ id: 'cart-item-1', productId: 'prod-1', quantity: 2 }],
+ }),
+ });
+ mockFetch.mockImplementationOnce(() => firstUpdate);
+ mockFetch.mockResolvedValueOnce({ ok: true });
+
+ render( );
+
+ await waitFor(() => expect(screen.getByText('2')).toBeInTheDocument());
+ fireEvent.click(
+ screen.getByRole('button', { name: /increase quantity/i }),
+ );
+ fireEvent.click(
+ screen.getByRole('button', { name: /increase quantity/i }),
+ );
+ await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3));
+
+ rejectFirstUpdate(new Error('Network error'));
+
+ await waitFor(() => expect(screen.getByText('4')).toBeInTheDocument());
+ expect(screen.getByText('Error')).toBeInTheDocument();
+ });
+
+ it('reports failures after optimistic quantity and removal mutations', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ items: [{ id: 'cart-item-1', productId: 'prod-1', quantity: 1 }],
+ }),
+ });
+ mockFetch.mockResolvedValueOnce({ ok: false });
+
+ render( );
+
+ await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', { name: /remove/i }));
+
+ await waitFor(() =>
+ expect(screen.getByText('Error')).toBeInTheDocument(),
+ );
+ });
});
});
diff --git a/tests/unit/components/cart/cart-item-matching.test.ts b/tests/unit/components/cart/cart-item-matching.test.ts
new file mode 100644
index 00000000..b3bafcbf
--- /dev/null
+++ b/tests/unit/components/cart/cart-item-matching.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import {
+ findCartItemInfo,
+ isCustomizationMatching,
+} from '@/modules/cart/presentation/components/cart-item-matching';
+
+const designPosition = {
+ imageUrl: 'https://example.com/design.png',
+ x: 0.25,
+ y: 0.5,
+ scale: 100,
+ rotation_deg: 0,
+ opacity: 100,
+ blend_mode: 'source-over' as const,
+};
+
+describe('cart item matching', () => {
+ it('matches design positions regardless of property order', () => {
+ expect(
+ isCustomizationMatching(
+ {
+ text: 'Design',
+ designPosition: {
+ opacity: 100,
+ blend_mode: 'source-over',
+ x: 0.25,
+ imageUrl: 'https://example.com/design.png',
+ rotation_deg: 0,
+ scale: 100,
+ y: 0.5,
+ },
+ },
+ { text: 'Design', designPosition },
+ ),
+ ).toBe(true);
+ });
+
+ it('does not match customizations with different design positions', () => {
+ expect(
+ findCartItemInfo(
+ [
+ {
+ id: 'cart-item-1',
+ productId: 'product-1',
+ quantity: 1,
+ customizations: [
+ { text: 'Design', designPosition: { ...designPosition, x: 0.3 } },
+ ],
+ },
+ ],
+ 'product-1',
+ { text: 'Design', designPosition },
+ ),
+ ).toBeNull();
+ });
+});
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 2a365f79..f8b38cab 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
@@ -24,12 +24,14 @@ import type { TransactionRunner } from '@/shared/kernel/transaction-runner';
class TransactionalCustomizationCreator implements CustomerCustomizationCreatePort {
readonly committed: CustomerCustomizationInput[] = [];
+ readonly createCalls: CustomerCustomizationInput[] = [];
async create(
input: CustomerCustomizationInput & { productId: string },
_userId: string,
- tx?: unknown,
+ tx: object,
): Promise<{ id: string; productId: string }> {
+ this.createCalls.push(input);
(
tx as { customizations: CustomerCustomizationInput[] }
).customizations.push(input);
@@ -103,6 +105,7 @@ describe('AddItemToCart', () => {
{ id: 'c2', productId: 'p1', text: 'World', color: 'blue', size: 'L' },
{ id: 'c3', productId: 'p2', text: 'Foo', color: 'green', size: 'S' },
{ id: 'c4', productId: 'p3', text: 'Bar', color: 'black', size: 'XL' },
+ { id: 'created-customization', productId: 'p1' },
]);
});
@@ -165,9 +168,35 @@ describe('AddItemToCart', () => {
).rejects.toThrow('Cart save failed');
expect(creator.committed).toEqual([]);
+ expect(creator.createCalls).toEqual([
+ { productId: 'p1', text: 'Atomic design' },
+ ]);
expect(await failingCartRepo.findActiveByUserId('u1')).toBeNull();
});
+ it('rejects a configured customization creator without a transaction runner', async () => {
+ const creator = new TransactionalCustomizationCreator();
+ const nonTransactionalUseCase = new AddItemToCart(
+ cartRepo,
+ productRepo,
+ outboxRepo,
+ customizationLookup,
+ undefined,
+ creator,
+ );
+
+ await expect(
+ nonTransactionalUseCase.execute({
+ userId: 'u1',
+ productId: 'p1',
+ quantity: 1,
+ customization: { text: 'Atomic design' },
+ }),
+ ).rejects.toThrow('Transaction runner is required');
+
+ expect(creator.createCalls).toEqual([]);
+ });
+
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 1cb53344..caee4cd7 100644
--- a/tests/unit/modules/cart/application/update-cart-item.test.ts
+++ b/tests/unit/modules/cart/application/update-cart-item.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { UpdateCartItemQuantity } from '@/modules/cart/application/update-cart-item';
import { MemoryCartRepository } from '@/tests/doubles/memory-cart-repository';
import { MemoryOutboxRepository } from '@/tests/doubles/memory-outbox-repository';
+import { MemoryCustomizationLookup } from '@/tests/doubles/memory-customization-lookup';
import { GlobalEvents } from '@/modules/events/domain/event-registry';
import { CartStatus } from '@/modules/cart/domain/value-objects/cart-status';
import { Currency } from '@/shared/kernel/domain/value-objects/currency';
@@ -13,6 +14,7 @@ import {
InvalidQuantityError,
ForbiddenError,
CartImmutableError,
+ InvalidCustomizationError,
} from '@/modules/cart/domain/errors';
import type { CartEntity } from '@/modules/cart/domain/entities/cart';
import type { CartItemEntity } from '@/modules/cart/domain/entities/cart-item';
@@ -24,12 +26,14 @@ import type { TransactionRunner } from '@/shared/kernel/transaction-runner';
class TransactionalCustomizationCreator implements CustomerCustomizationCreatePort {
readonly committed: CustomerCustomizationInput[] = [];
+ readonly createCalls: CustomerCustomizationInput[] = [];
async create(
input: CustomerCustomizationInput & { productId: string },
_userId: string,
- tx?: unknown,
+ tx: object,
): Promise<{ id: string; productId: string }> {
+ this.createCalls.push(input);
(
tx as { customizations: CustomerCustomizationInput[] }
).customizations.push(input);
@@ -94,12 +98,23 @@ const makeCart = (overrides: Partial = {}): CartEntity => ({
describe('UpdateCartItemQuantity', () => {
let cartRepo: MemoryCartRepository;
let outboxRepo: MemoryOutboxRepository;
+ let customizationLookup: MemoryCustomizationLookup;
let useCase: UpdateCartItemQuantity;
beforeEach(async () => {
cartRepo = new MemoryCartRepository();
outboxRepo = new MemoryOutboxRepository();
- useCase = new UpdateCartItemQuantity(cartRepo, outboxRepo);
+ customizationLookup = new MemoryCustomizationLookup();
+ useCase = new UpdateCartItemQuantity(
+ cartRepo,
+ outboxRepo,
+ customizationLookup,
+ );
+ customizationLookup.seed([
+ { id: 'c1', productId: 'p1' },
+ { id: 'c2', productId: 'p2' },
+ { id: 'created-customization', productId: 'p1' },
+ ]);
// Seed a basic active cart with one item.
await cartRepo.save(
@@ -151,6 +166,7 @@ describe('UpdateCartItemQuantity', () => {
const transactionalUseCase = new UpdateCartItemQuantity(
failingCartRepo,
outboxRepo,
+ customizationLookup,
new RecordingTransactionRunner(creator),
creator,
);
@@ -165,10 +181,57 @@ describe('UpdateCartItemQuantity', () => {
).rejects.toThrow('Cart save failed');
expect(creator.committed).toEqual([]);
+ expect(creator.createCalls).toEqual([
+ { productId: 'p1', text: 'Atomic design' },
+ ]);
const cart = await failingCartRepo.findActiveByUserId('u1');
expect(cart?.items[0]).toMatchObject({ customizationIdList: [] });
});
+ it('validates and deduplicates supplied customization IDs for the item product', async () => {
+ const item = await useCase.execute({
+ userId: 'u1',
+ itemId: 'i1',
+ quantity: 2,
+ customizationIdList: ['c1', 'c1'],
+ });
+
+ expect(item.customizationIdList).toEqual(['c1']);
+ });
+
+ it('rejects supplied customizations from another product', async () => {
+ await expect(
+ useCase.execute({
+ userId: 'u1',
+ itemId: 'i1',
+ quantity: 2,
+ customizationIdList: ['c2'],
+ }),
+ ).rejects.toBeInstanceOf(InvalidCustomizationError);
+ });
+
+ it('rejects a configured customization creator without a transaction runner', async () => {
+ const creator = new TransactionalCustomizationCreator();
+ const nonTransactionalUseCase = new UpdateCartItemQuantity(
+ cartRepo,
+ outboxRepo,
+ customizationLookup,
+ undefined,
+ creator,
+ );
+
+ await expect(
+ nonTransactionalUseCase.execute({
+ userId: 'u1',
+ itemId: 'i1',
+ quantity: 2,
+ customization: { text: 'Atomic design' },
+ }),
+ ).rejects.toThrow('Transaction runner is required');
+
+ expect(creator.createCalls).toEqual([]);
+ });
+
it('decreases quantity', async () => {
const item = await useCase.execute({
userId: 'u1',
diff --git a/tests/unit/modules/products/domain/value-objects/product-customization-config.test.ts b/tests/unit/modules/products/domain/value-objects/product-customization-config.test.ts
index 25667ee4..a8e120ee 100644
--- a/tests/unit/modules/products/domain/value-objects/product-customization-config.test.ts
+++ b/tests/unit/modules/products/domain/value-objects/product-customization-config.test.ts
@@ -90,4 +90,11 @@ describe('ProductCustomizationConfig', () => {
expect(config.toJson()).not.toHaveProperty('designChangeDescription');
expect(config.toJson()).not.toHaveProperty('tagNames');
});
+
+ it('enables text and photo customization when a customizable base is present', () => {
+ const config =
+ ProductCustomizationConfig.default().withCustomizableBase(true);
+
+ expect(config.mode).toBe('text_photo');
+ });
});