diff --git a/app/[locale]/products/[id]/customization-experience.tsx b/app/[locale]/products/[id]/customization-experience.tsx index f3e48f73..6e20144f 100644 --- a/app/[locale]/products/[id]/customization-experience.tsx +++ b/app/[locale]/products/[id]/customization-experience.tsx @@ -180,7 +180,7 @@ function CustomizationExperienceInner({ ProductCustomizationConfig.fromJson(customizationConfig); const resolvedCustomizationConfig = customizationConfig ?? ProductCustomizationConfig.default().toJson(); - const isAllowsPhoto = customizationModel.allowPhotoDesign !== false; + const isAllowsPhoto = customizationModel.allowsPhoto(); const customizableBaseImages = productImages.filter( (image) => image.purpose === ProductImagePurpose.CUSTOMIZABLE_BASE, ); diff --git a/app/[locale]/seller/products/product-form.tsx b/app/[locale]/seller/products/product-form.tsx index d6490c22..ce2e2727 100644 --- a/app/[locale]/seller/products/product-form.tsx +++ b/app/[locale]/seller/products/product-form.tsx @@ -447,10 +447,16 @@ function buildTranslationMap( 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, - allowPhotoDesign: form.images.customizableBase.length > 0, + mode: effectiveMode, } : undefined; const currentDesignChangeDescription = diff --git a/modules/cart/application/migrate-guest-cart.ts b/modules/cart/application/migrate-guest-cart.ts index 2815084d..27e33eb2 100644 --- a/modules/cart/application/migrate-guest-cart.ts +++ b/modules/cart/application/migrate-guest-cart.ts @@ -435,17 +435,21 @@ async function resolveGuestCustomizationIds( const cached = createdCustomizationCache.get(cacheKey); if (cached) return [cached]; - const created = await customizationCreator.create({ - productId: g.productId, - text: g.customizationText ?? null, - color: g.customizationColor ?? null, - size: g.customizationSize ?? null, - imageUrl: g.customizationImageUrl ?? null, - designPosition: g.customizationDesignPosition ?? null, - }); - - createdCustomizationCache.set(cacheKey, created.id); - return [created.id]; + try { + const created = await customizationCreator.create({ + productId: g.productId, + text: g.customizationText ?? null, + color: g.customizationColor ?? null, + size: g.customizationSize ?? null, + imageUrl: g.customizationImageUrl ?? null, + designPosition: g.customizationDesignPosition ?? null, + }); + + createdCustomizationCache.set(cacheKey, created.id); + return [created.id]; + } catch { + return null; + } } function guestCustomizationKey(g: GuestCartItem): string { @@ -471,7 +475,10 @@ function isCustomizationAllowed( (g.customizationColor !== undefined && g.customizationColor !== null) || (g.customizationSize !== undefined && g.customizationSize !== null); const hasPhoto = - g.customizationImageUrl !== undefined && g.customizationImageUrl !== null; + (g.customizationImageUrl !== undefined && + g.customizationImageUrl !== null) || + (g.customizationDesignPosition !== undefined && + g.customizationDesignPosition !== null); if (hasPhoto && !capability.allowsPhoto()) return false; if (hasStyle && !capability.allowsStyleOptions()) return false; diff --git a/modules/cart/presentation/components/add-to-cart-button.tsx b/modules/cart/presentation/components/add-to-cart-button.tsx index c56e8940..3de08204 100644 --- a/modules/cart/presentation/components/add-to-cart-button.tsx +++ b/modules/cart/presentation/components/add-to-cart-button.tsx @@ -168,6 +168,7 @@ export function AddToCartButton({ 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); @@ -203,6 +204,13 @@ export function AddToCartButton({ normalizedCustomization, ); setCartItemInfo(result.found); + setProductInCart( + !editCartItemId && + result.found === null && + (data.items ?? []).some( + (item: { productId: string }) => item.productId === productId, + ), + ); } catch { /* fallback to "Add to Cart" */ } @@ -263,6 +271,12 @@ export function AddToCartButton({ normalizedCustomization, ); setCartItemInfo(result.found); + setProductInCart( + result.found === null && + (data.items ?? []).some( + (item: { productId: string }) => item.productId === productId, + ), + ); } catch { /* ignore */ } @@ -674,6 +688,25 @@ export function AddToCartButton({ ); } + // 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 ( +
+ +
+ ); + } + // Default: "Add to Cart". return ( <> diff --git a/modules/cart/presentation/components/cart-merge-detector.tsx b/modules/cart/presentation/components/cart-merge-detector.tsx index d1ba6695..11367cd0 100644 --- a/modules/cart/presentation/components/cart-merge-detector.tsx +++ b/modules/cart/presentation/components/cart-merge-detector.tsx @@ -61,13 +61,16 @@ export function CartMergeDetector({ strategy: 'merge', }), }); - if (!res.ok) return; + if (!res.ok) { + hasCheckedRef.current = false; + return; + } // Clear guest cart after successful migration guestCart.clearCart(); dispatchCartUpdated(); router.refresh(); } catch { - // Ignore - let user retry manually + hasCheckedRef.current = false; } } } catch { diff --git a/modules/products/domain/value-objects/product-customization-config.ts b/modules/products/domain/value-objects/product-customization-config.ts index b91766e3..689665dc 100644 --- a/modules/products/domain/value-objects/product-customization-config.ts +++ b/modules/products/domain/value-objects/product-customization-config.ts @@ -44,7 +44,6 @@ export interface ProductCustomizationConfigJson { previewTemplateUrl: string | null; textOffset: PreviewOffset | null; imageOffset: PreviewOffset | null; - allowPhotoDesign?: boolean; categoryId?: string | null; } @@ -72,7 +71,6 @@ const productCustomizationConfigSchema = z previewTemplateUrl: z.string().min(1).nullable().optional(), textOffset: previewOffsetSchema.nullable().optional(), imageOffset: previewOffsetSchema.nullable().optional(), - allowPhotoDesign: z.boolean().optional(), categoryId: z.string().nullable().optional(), }) .strip(); @@ -85,7 +83,6 @@ export class ProductCustomizationConfig { previewTemplateUrl: null, textOffset: null, imageOffset: null, - allowPhotoDesign: true, categoryId: null, }); } @@ -96,15 +93,26 @@ export class ProductCustomizationConfig { return this.default(); } - const data = parsed.data; + const data = parsed.data as Record; + const raw = value as Record | null; + + // Backward compat: stored JSON may have allowPhotoDesign: true with + // mode: 'description' from before the flag was removed. + const mode: CustomizationMode = + (data.mode as CustomizationMode) ?? 'description'; + const effectiveMode: CustomizationMode = + mode === 'description' && + (raw?.allowPhotoDesign as boolean | undefined) === true + ? 'text_photo' + : mode; + return new ProductCustomizationConfig({ - mode: data.mode ?? 'description', - previewEnabled: data.previewEnabled ?? false, - previewTemplateUrl: data.previewTemplateUrl ?? null, - textOffset: data.textOffset ?? null, - imageOffset: data.imageOffset ?? null, - allowPhotoDesign: data.allowPhotoDesign ?? true, - categoryId: data.categoryId ?? null, + mode: effectiveMode, + previewEnabled: (data.previewEnabled as boolean) ?? false, + previewTemplateUrl: (data.previewTemplateUrl as string | null) ?? null, + textOffset: (data.textOffset as PreviewOffset | null) ?? null, + imageOffset: (data.imageOffset as PreviewOffset | null) ?? null, + categoryId: (data.categoryId as string | null) ?? null, }); } @@ -113,7 +121,6 @@ export class ProductCustomizationConfig { readonly previewTemplateUrl: string | null; readonly textOffset: PreviewOffset | null; readonly imageOffset: PreviewOffset | null; - readonly allowPhotoDesign: boolean; readonly categoryId: string | null; private constructor(data: ProductCustomizationConfigJson) { @@ -122,7 +129,6 @@ export class ProductCustomizationConfig { this.previewTemplateUrl = data.previewTemplateUrl; this.textOffset = data.textOffset; this.imageOffset = data.imageOffset; - this.allowPhotoDesign = data.allowPhotoDesign ?? true; this.categoryId = data.categoryId ?? null; } @@ -166,7 +172,6 @@ export class ProductCustomizationConfig { previewTemplateUrl: this.previewTemplateUrl, textOffset: this.textOffset, imageOffset: this.imageOffset, - allowPhotoDesign: this.allowPhotoDesign, categoryId: this.categoryId, }; } diff --git a/modules/products/presentation/schemas/product-form-schema.ts b/modules/products/presentation/schemas/product-form-schema.ts index a1dae034..d3877dcd 100644 --- a/modules/products/presentation/schemas/product-form-schema.ts +++ b/modules/products/presentation/schemas/product-form-schema.ts @@ -50,7 +50,6 @@ export const productCustomizationConfigSchema = z previewTemplateUrl: z.string().min(1).nullable(), textOffset: previewOffsetSchema.nullable(), imageOffset: previewOffsetSchema.nullable(), - allowPhotoDesign: z.boolean().optional(), categoryId: z.string().nullable().optional(), }) .strip(); diff --git a/prisma/seed-data.ts b/prisma/seed-data.ts index 187796a7..dece1136 100644 --- a/prisma/seed-data.ts +++ b/prisma/seed-data.ts @@ -167,7 +167,7 @@ export function buildSeedProducts(sellerId: string): SeedProductData[] { sellerId, status: 'ACTIVE', customizationConfig: { - mode: 'description', + mode: 'text_photo', previewEnabled: false, previewTemplateUrl: null, sizeOptions: null, diff --git a/prisma/seed.ts b/prisma/seed.ts index af50278f..6b30e0e5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -560,6 +560,19 @@ async function main() { for (const [index, p] of productsData.entries()) { const { tags: tagSlugs, translations, ...productFields } = p; const category = categories[index % categories.length]; + if ( + !('customizationConfig' in p) && + p.images?.create?.some((img) => img.purpose === 'CUSTOMIZABLE_BASE') + ) { + (productFields as Record).customizationConfig = { + mode: 'text_photo', + previewEnabled: false, + previewTemplateUrl: null, + textOffset: null, + imageOffset: null, + }; + } + const product = await prisma.product.create({ data: { ...productFields, diff --git a/tests/e2e/personalization-flow.spec.ts b/tests/e2e/personalization-flow.spec.ts index 41ca1bba..12a4255e 100644 --- a/tests/e2e/personalization-flow.spec.ts +++ b/tests/e2e/personalization-flow.spec.ts @@ -112,7 +112,7 @@ test.describe('Personalization flow', () => { const secondCustomizationCreation = waitForCustomizationCreation(customerPage); await customerPage - .getByRole('button', { name: 'Añadir al carrito' }) + .getByRole('button', { name: 'Añadir otra personalización' }) .click(); const secondCustomizationResponse = await secondCustomizationCreation; expect( diff --git a/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx b/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx index 43f9dc85..9ee5a39e 100644 --- a/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx +++ b/tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx @@ -223,7 +223,9 @@ describe('CustomizationExperience', () => { purpose: ProductImagePurpose.CUSTOMIZABLE_BASE, }, ]} - customizationConfig={ProductCustomizationConfig.default().toJson()} + customizationConfig={ProductCustomizationConfig.fromJson({ + mode: 'text_photo', + }).toJson()} labels={labels} initialDraft={{ color: 'Red' }} />, @@ -261,7 +263,9 @@ describe('CustomizationExperience', () => { purpose: ProductImagePurpose.CUSTOMIZABLE_BASE, }, ]} - customizationConfig={ProductCustomizationConfig.default().toJson()} + customizationConfig={ProductCustomizationConfig.fromJson({ + mode: 'text_photo', + }).toJson()} labels={labels} initialDraft={{ color: 'Red' }} />, 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 581d4f0c..25667ee4 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 @@ -10,7 +10,6 @@ describe('ProductCustomizationConfig', () => { expect(config.previewTemplateUrl).toBeNull(); expect(config.textOffset).toBeNull(); expect(config.imageOffset).toBeNull(); - expect(config.allowPhotoDesign).toBe(true); expect(config.isDefault()).toBe(true); expect(config.isPreviewCapable()).toBe(false); }); diff --git a/tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts b/tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts index b38e10ba..d156dae1 100644 --- a/tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts +++ b/tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts @@ -124,7 +124,6 @@ describe('PrismaProductRepository', () => { previewTemplateUrl: 'https://cdn.example.com/mock.png', textOffset: { x: 12, y: 20 }, imageOffset: { x: 18, y: 30 }, - allowPhotoDesign: true, categoryId: null, }), translations: { @@ -244,7 +243,6 @@ describe('PrismaProductRepository', () => { previewTemplateUrl: 'https://cdn.example.com/mock.png', textOffset: { x: 12, y: 20 }, imageOffset: { x: 18, y: 30 }, - allowPhotoDesign: true, categoryId: null, }), updatedAt: product.updatedAt,