diff --git a/app/[locale]/cart/page.tsx b/app/[locale]/cart/page.tsx index a24a7206..7b103d2a 100644 --- a/app/[locale]/cart/page.tsx +++ b/app/[locale]/cart/page.tsx @@ -95,6 +95,8 @@ export default async function CartPage({ customizationText: dict.common.customizationText, increaseQuantity: dict.common.increaseQuantity, decreaseQuantity: dict.common.decreaseQuantity, + customizationDesignImageAlt: dict.common.customizationDesignImageAlt, + price: dict.common.price, }} /> ); diff --git a/app/[locale]/checkout/page.module.css b/app/[locale]/checkout/page.module.css index cfa3898e..5e722e40 100644 --- a/app/[locale]/checkout/page.module.css +++ b/app/[locale]/checkout/page.module.css @@ -10,11 +10,15 @@ margin-bottom: 1.5rem; } +.checkoutCard { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + .sellerSection { - margin-bottom: 1.5rem; - padding: 1rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + padding-bottom: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); } .sellerName { @@ -22,7 +26,7 @@ font-weight: 600; margin-bottom: 0.75rem; padding-bottom: 0.5rem; - border-bottom: 1px solid #eee; + border-bottom: 1px solid var(--color-border); } .itemRow { @@ -46,22 +50,22 @@ .itemCustomization { font-size: 0.8rem; - color: #888; + color: var(--color-text-secondary); } .itemCustomizationRemoved { font-size: 0.8rem; - color: #c62828; + color: var(--color-danger); font-style: italic; } .warning { padding: 0.75rem 1rem; margin-bottom: 1rem; - background: #fff3e0; - border: 1px solid #ff9800; - border-radius: 6px; - color: #e65100; + background: var(--color-cream); + border: 1px solid var(--color-warning); + border-radius: var(--radius-md); + color: var(--color-green-dark); font-size: 0.9rem; } @@ -88,15 +92,14 @@ justify-content: space-between; padding-top: 0.5rem; margin-top: 0.5rem; - border-top: 1px solid #eee; + border-top: 1px solid var(--color-border); font-weight: 600; font-size: 0.95rem; } .totals { - margin: 1.5rem 0; - padding: 1rem; - background: #f9f9f9; + padding: var(--spacing-md); + background: var(--color-cream); border-radius: var(--radius-md); } @@ -114,7 +117,7 @@ .grandTotal { font-size: 1.2rem; font-weight: 700; - border-top: 2px solid #ddd; + border-top: 2px solid var(--color-border-strong); margin-top: 0.5rem; padding-top: 0.75rem; } diff --git a/app/[locale]/checkout/page.tsx b/app/[locale]/checkout/page.tsx index 4b3fcfd4..106d7b21 100644 --- a/app/[locale]/checkout/page.tsx +++ b/app/[locale]/checkout/page.tsx @@ -8,6 +8,8 @@ import type { ProductEntity } from '@/modules/products/domain/product-repository import { Money } from '@/shared/kernel/domain/value-objects/money'; import { Currency } from '@/shared/kernel/domain/value-objects/currency'; import type { CustomizationSnapshot } from '@/modules/cart/domain/customization-lookup-port'; +import { getDictionary } from '@/shared/i18n/get-dictionary'; +import { Card } from '@/shared/ui/card'; import Image from 'next/image'; import styles from './page.module.css'; @@ -63,6 +65,7 @@ export default async function CheckoutPage({ }) { const { locale } = await params; const session = await getServerSession(authOptions); + const dict = await getDictionary(locale as 'es' | 'cat'); if (!session?.user?.id) { redirect(`/${locale}/auth/signin?callbackUrl=/${locale}/checkout`); @@ -92,7 +95,7 @@ export default async function CheckoutPage({ (item) => item.unitPriceSnapshot.currency !== currency, ); if (hasMixedCurrencies) { - throw new Error('Checkout does not support mixed currencies'); + throw new Error(dict.common.genericError); } const customer = await userRepository.findById(session.user.id); @@ -132,10 +135,11 @@ export default async function CheckoutPage({ return { id: item.id, productId: item.productId.value, - productName: product?.translations?.[0]?.name ?? 'Unknown Product', + productName: + product?.translations?.[0]?.name ?? dict.common.unknownProduct, productImageUrl: product?.images?.[0]?.url ?? null, sellerId: item.sellerId.value, - sellerName: product?.sellerName ?? 'Unknown Seller', + sellerName: product?.sellerName ?? dict.common.unknownSeller, quantity: item.quantity, unitPrice: item.unitPriceSnapshot.amount, lineTotal: +(item.unitPriceSnapshot.amount * item.quantity).toFixed(2), @@ -185,92 +189,102 @@ export default async function CheckoutPage({ return (
-

Checkout

+

{dict.common.checkout}

- {hasMissingCustomizations && ( -
- Some customizations are no longer available and will not be included - in your order. -
- )} + + {hasMissingCustomizations && ( +
+ {dict.common.checkoutMissingCustomizations} +
+ )} - {sellerGroups.map((group) => ( -
-

{group.sellerName}

- {group.items.map((item) => ( -
-
- {item.productName} - {item.customizations.length > 0 && ( - - {[ - ...item.customizations.flatMap((c) => [ - c.size && `Size: ${c.size}`, - c.color && `Color: ${c.color}`, - c.text && `Text: ${c.text}`, - ]), - ] - .filter(Boolean) - .join(' · ')} + {sellerGroups.map((group) => ( +
+

{group.sellerName}

+ {group.items.map((item) => ( +
+
+ {item.productName} + {item.customizations.length > 0 && ( + + {[ + ...item.customizations.flatMap((c) => [ + c.size && + `${dict.common.customizationSize}: ${c.size}`, + c.color && + `${dict.common.customizationColor}: ${c.color}`, + c.text && + `${dict.common.customizationText}: ${c.text}`, + ]), + ] + .filter(Boolean) + .join(' · ')} + + )} + {item.customizations[0]?.imageUrl && ( + {dict.common.customizationPreview} + )} + {item.customizationIdList.length > + item.customizations.length && ( + + {dict.common.customizationRemoved} + + )} +
+
+ ×{item.quantity} + + {Money.format(item.lineTotal, item.currency)} - )} - {item.customizations[0]?.imageUrl && ( - Customization preview - )} - {item.customizationIdList.length > - item.customizations.length && ( - - Customization removed - - )} -
-
- ×{item.quantity} - - {Money.format(item.lineTotal, item.currency)} - +
+ ))} +
+ {dict.common.subtotal} + {Money.format(group.subtotal, currency)}
- ))} -
- Subtotal - {Money.format(group.subtotal, currency)}
-
- ))} + ))} -
-
- Subtotal - {Money.format(subtotal, currency)} -
- {isFirstPurchase && ( +
- - {FIRST_PURCHASE_DISCOUNT_RATE * 100}% first-purchase discount - - - −{Money.format(discount, currency)} - + {dict.common.subtotal} + {Money.format(subtotal, currency)} +
+ {isFirstPurchase && ( +
+ + {dict.common.firstPurchaseDiscount.replace( + '{rate}', + String(FIRST_PURCHASE_DISCOUNT_RATE * 100), + )} + + + −{Money.format(discount, currency)} + +
+ )} +
+ {dict.common.shipping} + {Money.format(shipping, currency)} +
+
+ {dict.common.total} + {Money.format(total, currency)}
- )} -
- Shipping - {Money.format(shipping, currency)} -
-
- Total - {Money.format(total, currency)}
-
- + +
); } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index fae38959..4788e69e 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -167,6 +167,7 @@ export default async function RootLayout({ unknownSeller: dict.common.unknownSeller, increaseQuantity: dict.common.increaseQuantity, decreaseQuantity: dict.common.decreaseQuantity, + close: dict.common.close, }} /> diff --git a/app/[locale]/orders/[orderId]/page.module.css b/app/[locale]/orders/[orderId]/page.module.css new file mode 100644 index 00000000..9c5d352d --- /dev/null +++ b/app/[locale]/orders/[orderId]/page.module.css @@ -0,0 +1,134 @@ +.container { + max-width: 700px; + margin: 0 auto; + padding: var(--spacing-xl); + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.title { + margin: 0 0 var(--spacing-lg); + font-size: 1.3rem; + font-weight: var(--font-weight-semibold); + color: var(--color-green-dark); +} + +.detailRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm) 0; +} + +.detailRow + .detailRow { + border-top: 1px solid var(--color-border); +} + +.detailLabel { + color: var(--color-text-secondary); + font-size: 0.9rem; +} + +.detailValue { + font-weight: var(--font-weight-medium); + font-size: 0.95rem; +} + +.itemsCard { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.itemsTitle { + margin: 0; + font-size: 1.05rem; + font-weight: var(--font-weight-semibold); + color: var(--color-green-dark); +} + +.itemsList { + display: flex; + flex-direction: column; +} + +.itemRow { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) 0; +} + +.itemRow + .itemRow { + border-top: 1px solid var(--color-border); +} + +.itemImage { + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; +} + +.itemInfo { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + flex: 1; +} + +.itemName { + font-weight: var(--font-weight-medium); + font-size: 0.95rem; +} + +.itemCustomization { + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.itemRight { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-shrink: 0; +} + +.itemQty { + color: var(--color-text-secondary); + font-size: 0.9rem; +} + +.itemLineTotal { + font-weight: var(--font-weight-semibold); + font-size: 0.95rem; + min-width: 5rem; + text-align: right; +} + +.noItems { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin: 0; +} + +.retrySection { + margin: 0; +} + +.retryButton { + width: 100%; + padding: 0.75rem; + background: var(--color-coral); + color: var(--color-white); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 1rem; + font-weight: var(--font-weight-medium); +} + +.retryButton:hover { + opacity: 0.85; +} diff --git a/app/[locale]/orders/[orderId]/page.tsx b/app/[locale]/orders/[orderId]/page.tsx index bf26becf..8785dadb 100644 --- a/app/[locale]/orders/[orderId]/page.tsx +++ b/app/[locale]/orders/[orderId]/page.tsx @@ -5,6 +5,18 @@ import { redirect, notFound } from 'next/navigation'; import { getDictionary } from '@/shared/i18n/get-dictionary'; import { Money } from '@/shared/kernel/domain/value-objects/money'; import { Currency } from '@/shared/kernel/domain/value-objects/currency'; +import { StatusBadge } from '@/shared/ui/status-badge'; +import { Card } from '@/shared/ui/card'; +import { BackLink } from '@/shared/ui/back-link'; +import { normalizeLocale } from '@/shared/i18n/normalize-locale'; +import Image from 'next/image'; +import styles from './page.module.css'; + +const ORDER_STATUS_LABELS: Record = { + new: 'new', + in_progress: 'inProgress', + completed: 'completed', +}; export default async function OrderDetailPage({ params, @@ -20,30 +32,123 @@ export default async function OrderDetailPage({ const dict = await getDictionary(locale as 'es' | 'cat'); const orderRepository = container.getOrderRepository(); - const order = await orderRepository.findById(orderId); + const order = await orderRepository.findById(orderId, locale); if (!order || order.userId !== session.user.id) { notFound(); } + const statusLabelKey = ORDER_STATUS_LABELS[order.status]; + const statusLabel = statusLabelKey + ? (dict.orders?.[statusLabelKey] ?? order.status) + : order.status; + + const items = order.lineItems ?? []; + return ( -
-

{order.id}

-

- {dict.orders?.status ?? 'Status'}: {order.status} -

-

- {dict.orders?.total ?? 'Total'}:{' '} - {Money.format(order.total, Currency.EUR)} -

+
+ ← {dict.orders?.myOrders} + + +

{dict.orders?.myOrders}

+ +
+ {dict.orders?.status} + +
+ +
+ {dict.orders?.total} + + {Money.format(order.total, Currency.EUR)} + +
+ + {order.createdAt && ( +
+ {dict.orders?.date} + + {new Date(order.createdAt).toLocaleDateString( + normalizeLocale(locale), + )} + +
+ )} +
+ + +

{dict.orders?.items}

+ + {items.length === 0 ? ( +

{dict.orders?.noItems}

+ ) : ( +
+ {items.map((item) => { + const customImage = item.customizationSnapshot?.find( + (c) => c.imageUrl, + )?.imageUrl; + const displayImage = customImage ?? item.productImageUrl; + const lineTotal = + item.unitPrice != null + ? item.unitPrice * item.quantity + : undefined; + + return ( +
+ {displayImage && ( + {item.productName + )} +
+ + {item.productName ?? item.productId} + + {item.customizationSnapshot && + item.customizationSnapshot.length > 0 && ( + + {item.customizationSnapshot + .flatMap((c) => [ + c.size && + `${dict.common.customizationSize}: ${c.size}`, + c.color && + `${dict.common.customizationColor}: ${c.color}`, + c.text && + `${dict.common.customizationText}: ${c.text}`, + ]) + .filter(Boolean) + .join(' · ')} + + )} +
+
+ ×{item.quantity} + {lineTotal !== undefined && ( + + {Money.format(lineTotal, Currency.EUR)} + + )} +
+
+ ); + })} +
+ )} +
+ {order.checkoutGroupId && order.checkoutGroupPaymentStatus === 'failed' && (
-
)} diff --git a/app/[locale]/orders/page.module.css b/app/[locale]/orders/page.module.css new file mode 100644 index 00000000..b6619ec8 --- /dev/null +++ b/app/[locale]/orders/page.module.css @@ -0,0 +1,88 @@ +.container { + max-width: 960px; + margin: 0 auto; + padding: var(--spacing-xl); +} + +.header { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +.headerLead { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); +} + +.searchWrap { + display: flex; + justify-content: flex-end; +} + +.title { + margin: 0; + font-size: 1.5rem; + font-weight: var(--font-weight-semibold); + color: var(--color-green-dark); +} + +.tableWrap { + overflow-x: auto; + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); +} + +.actionCell { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.retryForm { + display: inline; +} + +.retryButton { + padding: 0.3rem 0.7rem; + background: var(--color-coral); + color: var(--color-white); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + font-weight: var(--font-weight-medium); +} + +.retryButton:hover { + opacity: 0.85; +} + +.viewButton { + display: inline-block; + padding: 0.4rem 1rem; + background: var(--color-green-dark); + color: var(--color-white); + border: none; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: var(--font-weight-medium); + text-decoration: none; + cursor: pointer; +} + +.viewButton:hover { + opacity: 0.9; +} + +.emptyState { + text-align: center; + padding: var(--spacing-xl); + color: var(--color-lila); + font-size: 1rem; +} diff --git a/app/[locale]/orders/page.tsx b/app/[locale]/orders/page.tsx index 1d52d930..7b47b385 100644 --- a/app/[locale]/orders/page.tsx +++ b/app/[locale]/orders/page.tsx @@ -7,6 +7,20 @@ import { ListCustomerOrdersUseCase } from '@/modules/orders/application/list-cus import { getDictionary } from '@/shared/i18n/get-dictionary'; import { Money } from '@/shared/kernel/domain/value-objects/money'; import { Currency } from '@/shared/kernel/domain/value-objects/currency'; +import { DataTable } from '@/shared/ui/data-table'; +import type { DataTableColumn } from '@/shared/ui/data-table'; +import { StatusBadge } from '@/shared/ui/status-badge'; +import { Pagination } from '@/shared/ui/pagination'; +import { Card } from '@/shared/ui/card'; +import { SearchForm } from '@/shared/ui/search-form'; +import type { OrderEntity } from '@/modules/orders/domain/order-repository'; +import styles from './page.module.css'; + +const ORDER_STATUS_LABELS: Record = { + new: 'new', + in_progress: 'inProgress', + completed: 'completed', +}; export default async function CustomerOrdersPage({ params, @@ -18,6 +32,7 @@ export default async function CustomerOrdersPage({ page?: string; pageSize?: string; sortDir?: string; + q?: string; }>; }) { const { locale } = await params; @@ -40,83 +55,132 @@ export default async function CustomerOrdersPage({ }; const useCase = new ListCustomerOrdersUseCase(container.getOrderRepository()); - const result = await useCase.execute({ - userId: session.user.id, - status: filter.status, - page: filter.page, - pageSize: filter.pageSize, - sortBy: 'createdAt', - sortDir: filter.sortDir, - }); + const result = await useCase.execute( + { + userId: session.user.id, + status: filter.status, + page: filter.page, + pageSize: filter.pageSize, + sortBy: 'createdAt', + sortDir: filter.sortDir, + q: filter.q, + }, + locale, + ); - return ( -
-

{dict.orders?.myOrders ?? 'My orders'}

+ const columns: DataTableColumn[] = [ + { + key: 'status', + header: dict.orders?.status ?? 'Status', + render: (order) => { + const labelKey = ORDER_STATUS_LABELS[order.status]; + const label = labelKey + ? (dict.orders?.[labelKey] ?? order.status) + : order.status; + return ; + }, + }, + { + key: 'date', + header: dict.orders?.date ?? 'Date', + render: (order) => + order.createdAt + ? new Date(order.createdAt).toLocaleDateString(locale) + : '', + }, + { + key: 'total', + header: dict.orders?.total ?? 'Total', + render: (order) => Money.format(order.total, Currency.EUR), + }, + { + key: 'actions', + header: dict.orders?.actions ?? 'Actions', + render: (order) => ( +
+ + {dict.orders?.viewOrder ?? 'View order'} + + {order.checkoutGroupId && + order.checkoutGroupPaymentStatus === 'failed' && ( +
+ +
+ )} +
+ ), + }, + ]; - - - - - - - - - - - - {result.items.map((order) => ( - - - - - - - - ))} - -
{dict.orders?.id ?? 'ID'}{dict.orders?.status ?? 'Status'}{dict.orders?.date ?? 'Date'}{dict.orders?.total ?? 'Total'}{dict.orders?.actions ?? 'Actions'}
{order.id}{order.status} - {order.createdAt - ? new Date(order.createdAt).toLocaleDateString(locale) - : ''} - {Money.format(order.total, Currency.EUR)} - - {dict.orders?.viewOrder ?? 'View order'} - - {order.checkoutGroupId && - order.checkoutGroupPaymentStatus === 'failed' && ( -
- -
- )} -
+ const hasOrders = result.items.length > 0; + const currentPage = + result.totalPages > 0 && result.page > result.totalPages + ? result.totalPages + : result.page; - {result.totalPages > 1 && ( -
- {result.page > 1 && ( - - {dict.orders?.previous ?? '← Previous'} - - )} - - {(dict.orders?.pageXofY ?? 'Page {current} of {total}') - .replace('{current}', result.page.toString()) - .replace('{total}', result.totalPages.toString())} - - {result.page < result.totalPages && ( - - {dict.orders?.next ?? 'Next →'} - - )} + const buildPageUrl = (page: number) => { + const params = new URLSearchParams(); + if (page > 1) params.set('page', String(page)); + if (filter.status !== 'all') params.set('status', filter.status); + if (filter.sortDir !== 'desc') params.set('sortDir', filter.sortDir); + if (filter.pageSize !== 20) params.set('pageSize', String(filter.pageSize)); + if (filter.q) params.set('q', filter.q); + const qs = params.toString(); + return `/${locale}/orders${qs ? `?${qs}` : ''}`; + }; + + return ( +
+
+
+

+ {dict.orders?.myOrders ?? 'My orders'} +

+
+ +
+
+ + {hasOrders ? ( + <> +
+ o.id} + /> +
+ + + ) : ( + + {dict.orders?.noOrders ?? 'No orders yet'} + )}
); diff --git a/app/[locale]/products/[id]/page.tsx b/app/[locale]/products/[id]/page.tsx index 4b0316c1..7b01520c 100644 --- a/app/[locale]/products/[id]/page.tsx +++ b/app/[locale]/products/[id]/page.tsx @@ -27,9 +27,7 @@ export default async function ProductDetailPage({ } if (error || !product) { - return ( -
Error loading product details. Please check the server logs.
- ); + return
{dict.common.productDetailsError}
; } return ( @@ -51,7 +49,7 @@ export default async function ProductDetailPage({ ) : (
- Product Image Placeholder + {dict.common.customizationPreviewComingSoon}
)} @@ -63,7 +61,9 @@ export default async function ProductDetailPage({

{product.displayName}

{product.displayDescription}

{product.basePrice.format()}

-

Seller: {product.sellerName}

+

+ {dict.common.soldBy}: {product.sellerName} +

= { + new: 'new', + in_progress: 'inProgress', + completed: 'completed', +}; + export default async function SellerOrdersPage({ params, searchParams, @@ -46,14 +60,17 @@ export default async function SellerOrdersPage({ let result; try { - result = await useCase.execute({ - userId: session.user.id, - status: filter.status, - page: filter.page, - pageSize: filter.pageSize, - sortBy: 'createdAt', - sortDir: filter.sortDir, - }); + result = await useCase.execute( + { + userId: session.user.id, + status: filter.status, + page: filter.page, + pageSize: filter.pageSize, + sortBy: 'createdAt', + sortDir: filter.sortDir, + }, + locale, + ); } catch (error) { if ( error instanceof NotFoundError && @@ -64,103 +81,167 @@ export default async function SellerOrdersPage({ throw error; } - return ( -
-

{dict.sellerDashboard?.title ?? 'Seller orders'}

- -
- - - - -
- - - - - - - - - - - - - {result.items.map((order) => ( - - - - - - - - ))} - -
ID{dict.sellerDashboard?.status ?? 'Status'}{dict.sellerDashboard?.createdAt ?? 'Date'}{dict.sellerDashboard?.total ?? 'Total'}{dict.sellerDashboard?.actions ?? 'Actions'}
{order.id}{order.status} - {order.createdAt - ? new Date(order.createdAt).toLocaleDateString(locale) - : ''} - {Money.format(order.total, Currency.EUR)} - {order.status === 'new' && ( -
- - -
- )} - {order.status === 'in_progress' && ( -
- - -
- )} -
- - {result.totalPages > 1 && ( -
- {result.page > 1 && ( - [] = [ + { + key: 'id', + header: 'ID', + render: (order) => ( + #{order.id.slice(0, 8)} + ), + }, + { + key: 'status', + header: dict.sellerDashboard?.status ?? 'Status', + render: (order) => { + const labelKey = ORDER_STATUS_LABELS[order.status]; + const label = labelKey + ? (dict.orders?.[labelKey] ?? order.status) + : order.status; + return ; + }, + }, + { + key: 'date', + header: dict.sellerDashboard?.createdAt ?? 'Date', + render: (order) => + order.createdAt + ? new Date(order.createdAt).toLocaleDateString(locale) + : '', + }, + { + key: 'total', + header: dict.sellerDashboard?.total ?? 'Total', + render: (order) => Money.format(order.total, Currency.EUR), + }, + { + key: 'actions', + header: dict.sellerDashboard?.actions ?? 'Actions', + render: (order) => ( +
+ {order.status === 'new' && ( +
- {dict.admin?.pagePrev ?? '← Previous'} - + + +
)} - - {(dict.admin?.pageXofY ?? 'Page {current} of {total}') - .replace('{current}', result.page.toString()) - .replace('{total}', result.totalPages.toString())} - - {result.page < result.totalPages && ( - - {dict.admin?.pageNext ?? 'Next →'} - + + + )}
+ ), + }, + ]; + + const hasOrders = result.items.length > 0; + const currentPage = + result.totalPages > 0 && result.page > result.totalPages + ? result.totalPages + : result.page; + + const buildPageUrl = (page: number) => { + const params = new URLSearchParams(); + if (page > 1) params.set('page', String(page)); + if (filter.status !== 'all') params.set('status', filter.status); + if (filter.sortDir !== 'desc') params.set('sortDir', filter.sortDir); + if (filter.pageSize !== 20) params.set('pageSize', String(filter.pageSize)); + const qs = params.toString(); + return `/${locale}/seller/orders${qs ? `?${qs}` : ''}`; + }; + + return ( +
+
+
+

+ {dict.orders?.sellerOrdersTitle ?? 'Seller orders'} +

+
+ +
+
+ + +
+
+ + +
+ + +
+
+ + {hasOrders ? ( + <> +
+ o.id} + /> +
+ + + ) : ( + + {dict.orders?.noOrders ?? 'No orders yet'} + )}
); diff --git a/app/[locale]/seller/products/[id]/edit/page.tsx b/app/[locale]/seller/products/[id]/edit/page.tsx index c8c9c38f..89e425ed 100644 --- a/app/[locale]/seller/products/[id]/edit/page.tsx +++ b/app/[locale]/seller/products/[id]/edit/page.tsx @@ -88,6 +88,7 @@ export default async function SellerProductEditPage({ tagsPlaceholder: dict.sellerDashboard.productCustomizationTagsPlaceholder, tagsHelp: dict.sellerDashboard.productCustomizationTagsHelp, + addLabel: dict.sellerDashboard.productCustomizationAddLabel, }, }, gallery: { diff --git a/app/[locale]/seller/products/new/page.tsx b/app/[locale]/seller/products/new/page.tsx index 1348d391..ad7f76be 100644 --- a/app/[locale]/seller/products/new/page.tsx +++ b/app/[locale]/seller/products/new/page.tsx @@ -60,6 +60,7 @@ export default async function SellerProductCreatePage({ tagsPlaceholder: dict.sellerDashboard.productCustomizationTagsPlaceholder, tagsHelp: dict.sellerDashboard.productCustomizationTagsHelp, + addLabel: dict.sellerDashboard.productCustomizationAddLabel, }, }, gallery: { diff --git a/app/[locale]/seller/products/product-form.tsx b/app/[locale]/seller/products/product-form.tsx index 3a6a9523..30fa8577 100644 --- a/app/[locale]/seller/products/product-form.tsx +++ b/app/[locale]/seller/products/product-form.tsx @@ -50,6 +50,7 @@ interface ProductFormLabels { tagsLabel: string; tagsPlaceholder: string; tagsHelp: string; + addLabel: string; }; }; gallery: { diff --git a/app/api/orders/[orderId]/status/route.ts b/app/api/orders/[orderId]/status/route.ts index 0b3818e6..533e9c50 100644 --- a/app/api/orders/[orderId]/status/route.ts +++ b/app/api/orders/[orderId]/status/route.ts @@ -59,6 +59,19 @@ export const POST = requireRole('DESIGNER')(async function POST( } await orderRepository.updateStatus(orderId, status); + + const referer = request.headers.get('referer'); + if (referer) { + try { + const refererUrl = new URL(referer); + const baseUrl = new URL(request.url); + if (refererUrl.origin === baseUrl.origin) { + return NextResponse.redirect(referer, 303); + } + } catch { + // invalid URL — fall through to JSON response + } + } return NextResponse.json({ orderId, status }, { status: 200 }); } catch (error: unknown) { return handleApiError(error); diff --git a/eslint.config.mjs b/eslint.config.mjs index d5df12e6..61f11838 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,7 @@ import tseslint from 'typescript-eslint'; import eslintReact from '@eslint-react/eslint-plugin'; import reactHooks from 'eslint-plugin-react-hooks'; import nextPlugin from '@next/eslint-plugin-next'; +import i18next from 'eslint-plugin-i18next'; const CODE_FILES = ['**/*.{js,jsx,mjs,cjs,ts,tsx}']; @@ -109,6 +110,29 @@ export default [ ...nextPlugin.configs['core-web-vitals'].rules, }, }, + scopeToCodeFiles({ + ignores: ['**/tests/**'], + plugins: { i18next }, + rules: { + 'i18next/no-literal-string': [ + 'error', + { + mode: 'jsx-text-only', + 'jsx-attributes': { + include: ['alt', 'title', 'placeholder', 'aria-label'], + exclude: ['className', 'style', 'type', 'id', 'data-testid'], + }, + words: { + exclude: [ + '[0-9!-/:-@[-`{-~]+', + '^[A-Z_-]+$', + '^[\u00B7\u00D7\u2190-\u21FF\u2212\u2713-\u2717]+$', + ], + }, + }, + ], + }, + }), // JSON / JSONC / JSON5 { diff --git a/modules/cart/presentation/components/add-to-cart-button.tsx b/modules/cart/presentation/components/add-to-cart-button.tsx index c54c1323..e04b3319 100644 --- a/modules/cart/presentation/components/add-to-cart-button.tsx +++ b/modules/cart/presentation/components/add-to-cart-button.tsx @@ -25,6 +25,7 @@ export interface CartButtonLabels { decreaseQuantity?: string; increaseQuantity?: string; saveDesign?: string; + savingDesign?: string; alreadyInCart?: string; alreadyInCartDifferent?: string; } @@ -629,7 +630,9 @@ export function AddToCartButton({ onClick={handleSaveDesign} disabled={savingDesign} > - {savingDesign ? '...' : labels.saveDesign} + {savingDesign + ? (labels.savingDesign ?? labels.saveDesign) + : labels.saveDesign} )} diff --git a/modules/cart/presentation/components/cart-view.tsx b/modules/cart/presentation/components/cart-view.tsx index 99c4f16a..2667ae4c 100644 --- a/modules/cart/presentation/components/cart-view.tsx +++ b/modules/cart/presentation/components/cart-view.tsx @@ -70,6 +70,8 @@ interface CartViewProps { decreaseQuantity: string; increaseQuantity: string; customizationEditFromCart?: string; + customizationDesignImageAlt: string; + price: string; }; } @@ -298,7 +300,7 @@ export function CartView({ // eslint-disable-next-line @next/next/no-img-element Dise\u00f1o subido )} @@ -318,7 +320,7 @@ export function CartView({ {item.customization.color && ( - Estilo: {item.customization.color} + {labels.customizationColor}: {item.customization.color} )} @@ -351,7 +353,7 @@ export function CartView({
- Precio:{' '} + {labels.price}:{' '} {Money.format(item.unitPrice, Currency.EUR)} diff --git a/modules/cart/presentation/components/checkout-confirm-button.module.css b/modules/cart/presentation/components/checkout-confirm-button.module.css index 3b1b9879..ab5a1437 100644 --- a/modules/cart/presentation/components/checkout-confirm-button.module.css +++ b/modules/cart/presentation/components/checkout-confirm-button.module.css @@ -1,13 +1,32 @@ +.addressCard { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.addressTitle { + font-size: 1.05rem; + font-weight: var(--font-weight-semibold); + color: var(--color-green-dark); +} + +.addressForm { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); +} + .button { - width: 100%; - padding: 1rem; - background: var(--color-green-dark, var(--color-green-dark)); - color: #fff; + width: fit-content; + padding: 0.75rem 1.5rem; + background: var(--color-green-dark); + color: var(--color-white); border: none; border-radius: var(--radius-sm); cursor: pointer; - font-size: 1.15rem; - font-weight: 600; + font-size: 1rem; + font-weight: var(--font-weight-semibold); } .button:hover:not(:disabled) { @@ -28,8 +47,14 @@ margin-bottom: 0.5rem; } +.error { + color: var(--color-coral); + font-size: 0.9rem; + margin-bottom: var(--spacing-md); +} + .dialog p { - color: #555; + color: var(--color-text-secondary); margin-bottom: 1rem; } @@ -42,7 +67,7 @@ .priceList li { padding: 0.5rem 0; - border-bottom: 1px solid #eee; + border-bottom: 1px solid var(--color-border); font-size: 0.95rem; } @@ -54,12 +79,12 @@ .acceptButton { padding: 0.75rem; - background: var(--color-green-dark, var(--color-green-dark)); - color: #fff; + background: var(--color-green-dark); + color: var(--color-white); border: none; border-radius: var(--radius-sm); cursor: pointer; - font-weight: 600; + font-weight: var(--font-weight-semibold); } .cancelButton { diff --git a/modules/cart/presentation/components/checkout-confirm-button.tsx b/modules/cart/presentation/components/checkout-confirm-button.tsx index d671006e..8d091635 100644 --- a/modules/cart/presentation/components/checkout-confirm-button.tsx +++ b/modules/cart/presentation/components/checkout-confirm-button.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useGuestCart } from '@/modules/cart/presentation/guest-cart-context'; +import { useDictionary } from '@/shared/i18n/dictionary-context'; import { Modal } from '@/shared/ui/modal'; import { TextField } from '@/shared/ui/text-field'; import { Money } from '@/shared/kernel/domain/value-objects/money'; @@ -48,6 +49,7 @@ export function CheckoutConfirmButton({ }: CheckoutConfirmButtonProps) { const router = useRouter(); const { clearCart } = useGuestCart(); + const dict = useDictionary(); const [loading, setLoading] = useState(false); const [priceChanges, setPriceChanges] = useState(null); const [error, setError] = useState(null); @@ -61,7 +63,7 @@ export function CheckoutConfirmButton({ const persistProfileAddress = async () => { if (!hasCompleteAddress) { - setError('Complete the address to continue'); + setError(dict.common.completeAddressToContinue); return false; } @@ -72,7 +74,7 @@ export function CheckoutConfirmButton({ }); if (!profileRes.ok) { - setError('Unable to save the shipping address'); + setError(dict.common.unableToSaveAddress); return false; } @@ -100,7 +102,7 @@ export function CheckoutConfirmButton({ // Preview OK → confirm immediately (no price changes). await confirmCheckout(false); } catch { - setError('Unable to complete checkout'); + setError(dict.common.unableToCheckout); setLoading(false); } }; @@ -132,58 +134,58 @@ export function CheckoutConfirmButton({ return ( <> -
-

Delivery address

- setAddress((prev) => ({ ...prev, street }))} - /> - setAddress((prev) => ({ ...prev, city }))} - /> - - setAddress((prev) => ({ ...prev, postalCode })) - } - /> - setAddress((prev) => ({ ...prev, country }))} - /> -
- - {error && ( -
- {error} +
+

{dict.common.deliveryAddress}

+
+ setAddress((prev) => ({ ...prev, street }))} + /> + setAddress((prev) => ({ ...prev, city }))} + /> + + setAddress((prev) => ({ ...prev, postalCode })) + } + /> + setAddress((prev) => ({ ...prev, country }))} + />
- )} - + {error && ( +
+ {error} +
+ )} + + +
{priceChanges && ( setPriceChanges(null)}>
-

Price Change Detected

-

- The price of some items has changed since you added them to your - cart: -

+

{dict.common.priceChangeDetected}

+

{dict.common.priceChangeDescription}

    {priceChanges.map((pc) => (
  • - {Money.format(pc.oldPrice, Currency.EUR)} →{' '} + {Money.format(pc.oldPrice, Currency.EUR)} + {'\u00a0→\u00a0'} {Money.format(pc.newPrice, Currency.EUR)}
  • ))} @@ -193,7 +195,7 @@ export function CheckoutConfirmButton({ className={styles.acceptButton} onClick={() => confirmCheckout(true)} > - Accept new prices + {dict.common.acceptNewPrices}
diff --git a/modules/orders/application/create-order-use-case.ts b/modules/orders/application/create-order-use-case.ts index 7eb8e5bb..e0e619e5 100644 --- a/modules/orders/application/create-order-use-case.ts +++ b/modules/orders/application/create-order-use-case.ts @@ -90,6 +90,7 @@ export class CreateOrderUseCase { quantity: item.quantity, customizationIdList: [], customizationSnapshot: null, + unitPrice: product.basePrice, }); } diff --git a/modules/orders/application/handle-cart-checked-out.ts b/modules/orders/application/handle-cart-checked-out.ts index 441460e9..271e9c92 100644 --- a/modules/orders/application/handle-cart-checked-out.ts +++ b/modules/orders/application/handle-cart-checked-out.ts @@ -158,6 +158,7 @@ async function buildLineItems( id: crypto.randomUUID(), orderId, productId: item.productId, + unitPrice: item.unitPrice, quantity: item.quantity, customizationIdList: item.customizationIdList ?? [], customizationSnapshot: await resolveCustomizationSnapshot( diff --git a/modules/orders/application/list-customer-orders-use-case.ts b/modules/orders/application/list-customer-orders-use-case.ts index eab4f182..81e4f564 100644 --- a/modules/orders/application/list-customer-orders-use-case.ts +++ b/modules/orders/application/list-customer-orders-use-case.ts @@ -14,11 +14,15 @@ export class ListCustomerOrdersUseCase { async execute( dto: ListCustomerOrdersDTO, + locale?: string, ): Promise> { const { userId, ...filter } = dto; - return this.orderRepository.findPaginated({ - ...filter, - userId, - }); + return this.orderRepository.findPaginated( + { + ...filter, + userId, + }, + locale, + ); } } diff --git a/modules/orders/application/list-seller-orders-use-case.ts b/modules/orders/application/list-seller-orders-use-case.ts index b95838d8..20dd8c2d 100644 --- a/modules/orders/application/list-seller-orders-use-case.ts +++ b/modules/orders/application/list-seller-orders-use-case.ts @@ -19,6 +19,7 @@ export class ListSellerOrdersUseCase { async execute( dto: ListSellerOrdersDTO, + locale?: string, ): Promise> { const seller = await this.sellerLookup.findByUserId(dto.userId); if (!seller) { @@ -26,9 +27,12 @@ export class ListSellerOrdersUseCase { } const { userId: _userId, ...filter } = dto; - return this.orderRepository.findPaginated({ - ...filter, - sellerId: seller.sellerId, - }); + return this.orderRepository.findPaginated( + { + ...filter, + sellerId: seller.sellerId, + }, + locale, + ); } } diff --git a/modules/orders/domain/entities/order-line-item.ts b/modules/orders/domain/entities/order-line-item.ts index 1f595fb7..56cac5b0 100644 --- a/modules/orders/domain/entities/order-line-item.ts +++ b/modules/orders/domain/entities/order-line-item.ts @@ -11,6 +11,12 @@ export interface OrderLineItemEntity { orderId: string; /** ID of the product being ordered */ productId: string; + /** Product name (enriched at query time) */ + productName?: string; + /** Product image URL (snapshot at checkout time) */ + productImageUrl?: string | null; + /** Unit price at checkout time */ + unitPrice: number; /** Quantity of this product */ quantity: number; /** References to Customization entities (historical) */ diff --git a/modules/orders/domain/order-repository.ts b/modules/orders/domain/order-repository.ts index b493cf5c..278563f0 100644 --- a/modules/orders/domain/order-repository.ts +++ b/modules/orders/domain/order-repository.ts @@ -10,7 +10,10 @@ export type { OrderEntity, OrderLineItemEntity, OrderStatus }; * Follows the Repository pattern for data access abstraction. */ export interface OrderRepository { - findPaginated(filter: OrderListFilter): Promise>; + findPaginated( + filter: OrderListFilter, + locale?: string, + ): Promise>; /** * Saves an order entity to the database. @@ -33,9 +36,10 @@ export interface OrderRepository { /** * Finds an order by its unique identifier. * @param orderId - The unique ID of the order to find + * @param locale - The locale for product name resolution * @returns The order entity if found, null otherwise */ - findById(orderId: string): Promise; + findById(orderId: string, locale?: string): Promise; /** * Updates the status of an order. @@ -67,6 +71,7 @@ export interface OrderListFilter { userId?: string; sellerId?: string; status?: OrderStatus | 'all'; + q?: string; sortBy?: 'createdAt'; sortDir?: 'asc' | 'desc'; page?: number; diff --git a/modules/orders/infrastructure/prisma-order-repository.ts b/modules/orders/infrastructure/prisma-order-repository.ts index 6230b37f..8b6defff 100644 --- a/modules/orders/infrastructure/prisma-order-repository.ts +++ b/modules/orders/infrastructure/prisma-order-repository.ts @@ -20,6 +20,7 @@ type PrismaTx = Omit< export class PrismaOrderRepository implements OrderRepository { async findPaginated( filter: OrderListFilter, + locale?: string, ): Promise> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -29,12 +30,36 @@ export class PrismaOrderRepository implements OrderRepository { if (filter.userId) where.userId = filter.userId; if (filter.sellerId) where.sellerId = filter.sellerId; if (filter.status && filter.status !== 'all') where.status = filter.status; + if (filter.q) { + where.lineItems = { + some: { + product: { + translations: { + some: { + name: { contains: filter.q, mode: 'insensitive' }, + }, + }, + }, + }, + }; + } + + const translationLocale = locale ?? 'es'; const [rows, total] = await prisma.$transaction([ prisma.order.findMany({ where, include: { - lineItems: true, + lineItems: { + include: { + product: { + include: { + translations: { where: { locale: translationLocale } }, + images: { take: 1, orderBy: { position: 'asc' } }, + }, + }, + }, + }, checkoutGroup: { select: { paymentStatus: true } }, }, orderBy: { createdAt: sortDir }, @@ -55,6 +80,10 @@ export class PrismaOrderRepository implements OrderRepository { id: item.id, orderId: item.orderId, productId: item.productId, + productName: item.product?.translations?.[0]?.name, + productImageUrl: + item.product?.images?.[0]?.url ?? item.productImageUrl, + unitPrice: Number(item.unitPrice), quantity: item.quantity, customizationIdList: item.customizationIdList, customizationSnapshot: coerceCustomizationSnapshot( @@ -143,10 +172,12 @@ export class PrismaOrderRepository implements OrderRepository { // Create OrderLineItem records associated with the orderId await tx.orderLineItem.createMany({ data: lineItems.map((item) => ({ - id: item.id, // Assuming IDs are generated or passed correctly + id: item.id, orderId: orderId, productId: item.productId, quantity: item.quantity, + unitPrice: item.unitPrice, + productImageUrl: item.productImageUrl, customizationIdList: item.customizationIdList, customizationSnapshot: (item.customizationSnapshot ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue, @@ -154,11 +185,24 @@ export class PrismaOrderRepository implements OrderRepository { }); } - async findById(orderId: string): Promise { + async findById( + orderId: string, + locale?: string, + ): Promise { + const translationLocale = locale ?? 'es'; const order = await prisma.order.findUnique({ where: { id: orderId }, include: { - lineItems: true, + lineItems: { + include: { + product: { + include: { + translations: { where: { locale: translationLocale } }, + images: { take: 1, orderBy: { position: 'asc' } }, + }, + }, + }, + }, checkoutGroup: { select: { paymentStatus: true } }, }, }); @@ -176,6 +220,9 @@ export class PrismaOrderRepository implements OrderRepository { id: item.id, orderId: item.orderId, productId: item.productId, + productName: item.product?.translations?.[0]?.name, + productImageUrl: item.product?.images?.[0]?.url ?? item.productImageUrl, + unitPrice: Number(item.unitPrice), quantity: item.quantity, customizationIdList: item.customizationIdList, customizationSnapshot: coerceCustomizationSnapshot( diff --git a/modules/orders/presentation/schemas/order-schemas.ts b/modules/orders/presentation/schemas/order-schemas.ts index 345b471a..5d861201 100644 --- a/modules/orders/presentation/schemas/order-schemas.ts +++ b/modules/orders/presentation/schemas/order-schemas.ts @@ -13,4 +13,5 @@ export const orderListQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), sortDir: z.enum(['asc', 'desc']).default('desc'), + q: z.string().trim().max(200).optional(), }); diff --git a/modules/products/presentation/components/product-customization-config-editor.tsx b/modules/products/presentation/components/product-customization-config-editor.tsx index cc600132..a327c454 100644 --- a/modules/products/presentation/components/product-customization-config-editor.tsx +++ b/modules/products/presentation/components/product-customization-config-editor.tsx @@ -17,6 +17,7 @@ interface ProductCustomizationConfigEditorLabels { tagsLabel: string; tagsPlaceholder: string; tagsHelp: string; + addLabel: string; } interface ProductCustomizationConfigEditorProps { @@ -49,7 +50,8 @@ export function ProductCustomizationConfigEditor({ update({ sizeOptions: next })} /> @@ -65,7 +67,7 @@ export function ProductCustomizationConfigEditor({ update({ tagNames: next })} diff --git a/package-lock.json b/package-lock.json index c57ef6cc..77870100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "cross-env": "^10.1.0", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-i18next": "^6.1.5", "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", "jsdom": "^29.1.1", @@ -5086,6 +5087,19 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-i18next": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.5.tgz", + "integrity": "sha512-xCTfstbK9ZpQ6UFT5S1s6zbZMhKn2o2jRSQYiU2UkV7wt8y1m3WjkUYGkGlipgRdFInYI9+LAqE+JQU/L73HmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "requireindex": "~1.1.0" + }, + "engines": { + "node": ">=18.10.0" + } + }, "node_modules/eslint-plugin-react-dom": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.11.0.tgz", @@ -7611,6 +7625,16 @@ "node": ">=0.10.0" } }, + "node_modules/requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.5" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", diff --git a/package.json b/package.json index 956cc99a..c0963f7c 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "cross-env": "^10.1.0", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-i18next": "^6.1.5", "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", "jsdom": "^29.1.1", @@ -113,5 +114,12 @@ "typescript": "^6", "typescript-eslint": "^8.62.1", "vitest": "^4.1.9" + }, + "allowScripts": { + "@prisma/engines@7.8.0": true, + "prisma@7.8.0": true, + "bcrypt@6.0.0": true, + "esbuild@0.28.1": true, + "sharp@0.34.5": true } } diff --git a/prisma/migrations/20260705193304_add_order_line_item_unit_price/migration.sql b/prisma/migrations/20260705193304_add_order_line_item_unit_price/migration.sql new file mode 100644 index 00000000..6cc8640f --- /dev/null +++ b/prisma/migrations/20260705193304_add_order_line_item_unit_price/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "OrderLineItem" ADD COLUMN "productImageUrl" TEXT, +ADD COLUMN "unitPrice" DECIMAL(65,30) NOT NULL DEFAULT 0; + +-- CreateIndex +CREATE INDEX "ProductTranslation_name_idx" ON "ProductTranslation"("name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b183009e..d4aeedc7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,7 @@ model ProductTranslation { product Product @relation(fields: [productId], references: [id], onDelete: Cascade) @@unique([productId, locale]) + @@index([name]) } model SearchHistory { @@ -174,6 +175,8 @@ model OrderLineItem { productId String product Product @relation(fields: [productId], references: [id]) quantity Int + unitPrice Decimal @default(0) + productImageUrl String? customizationIdList String[] @default([]) customizationSnapshot Json? createdAt DateTime @default(now()) diff --git a/shared/i18n/locales/cat.json b/shared/i18n/locales/cat.json index 904116ec..30c35a1d 100644 --- a/shared/i18n/locales/cat.json +++ b/shared/i18n/locales/cat.json @@ -64,6 +64,19 @@ "checkout": "Finalitzar compra", "viewFullCart": "Veure cistella completa", "subtotal": "Subtotal", + "total": "Total", + "shipping": "Enviament", + "firstPurchaseDiscount": "{rate}% de descompte en la teva primera compra", + "deliveryAddress": "Adreça d'enviament", + "placeOrder": "Fer comanda", + "processing": "Processant...", + "completeAddressToContinue": "Completa l'adreça per continuar", + "unableToSaveAddress": "No s'ha pogut guardar l'adreça d'enviament", + "unableToCheckout": "No s'ha pogut completar la compra", + "priceChangeDetected": "Canvi de preu detectat", + "priceChangeDescription": "El preu d'alguns productes ha canviat des que els vas afegir al carret:", + "acceptNewPrices": "Acceptar nous preus", + "checkoutMissingCustomizations": "Algunes personalitzacions ja no estan disponibles i no s'inclouran a la teva comanda.", "loadingCart": "Carregant...", "customizationSize": "Mida", "customizationSizePlaceholder": "Selecciona una mida", @@ -118,7 +131,9 @@ "cartMergeKeepServerHint": "Descarta els articles de la cistella de convidat", "cartMergeKeepGuest": "Conservar la cistella de convidat", "cartMergeKeepGuestHint": "Substitueix la cistella de l'usuari pels articles de convidat", - "genericError": "Alguna cosa ha anat malament. Torna-ho a intentar." + "genericError": "Alguna cosa ha anat malament. Torna-ho a intentar.", + "customizationRemoved": "Personalització eliminada", + "price": "Preu" }, "auth": { "signInTitle": "Iniciar sessió", @@ -178,7 +193,9 @@ "deleteAccount": "Eliminar compte", "closeSession": "Tancar sessió", "dashboard": "Panell d'administració", - "designerPanel": "Panell de dissenyador" + "designerPanel": "Panell de dissenyador", + "myOrders": "Les meves comandes", + "receivedOrders": "Comandes rebudes" }, "sellerDashboard": { "title": "Llistat de productes", @@ -203,6 +220,7 @@ "productCustomizationTagsHelp": "Separa les etiquetes amb comes.", "productCustomizationSizeOptionsLabel": "Talles disponibles", "productCustomizationSizeOptionsPlaceholder": "S, M, L", + "productCustomizationAddLabel": "Afegir", "productPhotosTitle": "Fotos del producte", "productPhotosHint": "Carrega variants i anomena-les per color o acabat.", "addProductPhotos": "Afegir fotos", @@ -247,7 +265,25 @@ "viewOrder": "Veure comanda", "previous": "← Anterior", "next": "Següent →", - "pageXofY": "Pàgina {current} de {total}" + "pageXofY": "Pàgina {current} de {total}", + "sellerOrdersTitle": "Comandes de la botiga", + "filterStatus": "Filtrar per estat", + "all": "Tots", + "new": "Nou", + "inProgress": "En progrés", + "completed": "Completat", + "sortBy": "Ordenar per", + "sortDescending": "Més recents", + "sortAscending": "Més antics", + "createdAt": "Data de creació", + "moveToInProgress": "Moure a en progrés", + "markCompleted": "Marcar completat", + "searchItems": "Cercar per producte", + "searchItemsPlaceholder": "Cercar a les meves comandes...", + "items": "Productes", + "qty": "Quant.", + "noItems": "Aquesta comanda no té productes", + "noOrders": "Encara no tens comandes" }, "admin": { "sellersTitle": "Gestió de venedors", diff --git a/shared/i18n/locales/es.json b/shared/i18n/locales/es.json index 8be05595..a85ae256 100644 --- a/shared/i18n/locales/es.json +++ b/shared/i18n/locales/es.json @@ -64,6 +64,19 @@ "checkout": "Finalizar compra", "viewFullCart": "Ver carrito completo", "subtotal": "Subtotal", + "total": "Total", + "shipping": "Envío", + "firstPurchaseDiscount": "{rate}% de descuento en tu primera compra", + "deliveryAddress": "Dirección de envío", + "placeOrder": "Realizar pedido", + "processing": "Procesando...", + "completeAddressToContinue": "Completa la dirección para continuar", + "unableToSaveAddress": "No se pudo guardar la dirección de envío", + "unableToCheckout": "No se pudo completar la compra", + "priceChangeDetected": "Cambio de precio detectado", + "priceChangeDescription": "El precio de algunos productos ha cambiado desde que los añadiste al carrito:", + "acceptNewPrices": "Aceptar nuevos precios", + "checkoutMissingCustomizations": "Algunas personalizaciones ya no están disponibles y no se incluirán en tu pedido.", "loadingCart": "Cargando...", "customizationSize": "Talla", "customizationSizePlaceholder": "Selecciona una talla", @@ -118,7 +131,9 @@ "cartMergeKeepServerHint": "Descarta los artículos del carrito de invitado", "cartMergeKeepGuest": "Conservar el carrito de invitado", "cartMergeKeepGuestHint": "Reemplaza el carrito del servidor con los artículos de invitado", - "genericError": "Algo salió mal. Inténtalo de nuevo." + "genericError": "Algo salió mal. Inténtalo de nuevo.", + "customizationRemoved": "Personalización eliminada", + "price": "Precio" }, "auth": { "signInTitle": "Iniciar sesión", @@ -178,7 +193,9 @@ "deleteAccount": "Eliminar cuenta", "closeSession": "Cerrar sesión", "dashboard": "Panel de administración", - "designerPanel": "Panel de diseñador" + "designerPanel": "Panel de diseñador", + "myOrders": "Mis pedidos", + "receivedOrders": "Pedidos recibidos" }, "sellerDashboard": { "title": "Listado de productos", @@ -203,6 +220,7 @@ "productCustomizationTagsHelp": "Separa las etiquetas con comas.", "productCustomizationSizeOptionsLabel": "Tallas disponibles", "productCustomizationSizeOptionsPlaceholder": "S, M, L", + "productCustomizationAddLabel": "Añadir", "productPhotosTitle": "Fotos del producto", "productPhotosHint": "Sube variantes y nómbralas por color o acabado.", "addProductPhotos": "Añadir fotos", @@ -247,7 +265,25 @@ "viewOrder": "Ver pedido", "previous": "← Anterior", "next": "Siguiente →", - "pageXofY": "Página {current} de {total}" + "pageXofY": "Página {current} de {total}", + "sellerOrdersTitle": "Pedidos de la tienda", + "filterStatus": "Filtrar por estado", + "all": "Todos", + "new": "Nuevo", + "inProgress": "En progreso", + "completed": "Completado", + "sortBy": "Ordenar por", + "sortDescending": "Más recientes", + "sortAscending": "Más antiguos", + "createdAt": "Fecha de creación", + "moveToInProgress": "Mover a en progreso", + "markCompleted": "Marcar completado", + "searchItems": "Buscar por producto", + "searchItemsPlaceholder": "Buscar en mis pedidos...", + "items": "Productos", + "qty": "Cant.", + "noItems": "Este pedido no tiene productos", + "noOrders": "Aún no tienes pedidos" }, "admin": { "sellersTitle": "Gestión de vendedores", diff --git a/shared/i18n/normalize-locale.ts b/shared/i18n/normalize-locale.ts new file mode 100644 index 00000000..119297c0 --- /dev/null +++ b/shared/i18n/normalize-locale.ts @@ -0,0 +1,7 @@ +const LOCALE_ALIASES: Record = { + cat: 'ca', +}; + +export function normalizeLocale(locale: string): string { + return LOCALE_ALIASES[locale] ?? locale; +} diff --git a/shared/layout/user-menu-dropdown.tsx b/shared/layout/user-menu-dropdown.tsx index 6dd8e496..5d6d3dc2 100644 --- a/shared/layout/user-menu-dropdown.tsx +++ b/shared/layout/user-menu-dropdown.tsx @@ -77,13 +77,33 @@ export function UserMenuDropdown(_props: UserMenuDropdownProps) { )} {_props.user?.role === 'DESIGNER' && ( + <> + + {dict.userMenu.designerPanel} + + + {dict.userMenu.receivedOrders} + + + )} + {_props.user?.role !== 'DESIGNER' && ( - {dict.userMenu.designerPanel} + {dict.userMenu.myOrders} )} void; error?: string; required?: boolean; + currencySymbol?: string; } export function PriceField({ @@ -17,6 +18,7 @@ export function PriceField({ onChange, error, required, + currencySymbol = '\u20AC', }: PriceFieldProps) { const id = useId(); const handleChange = (e: ChangeEvent) => { @@ -42,7 +44,7 @@ export function PriceField({ aria-describedby={errorId} className={`${styles.input} ${error ? styles.inputError : ''}`} /> - + {currencySymbol}
{error && ( diff --git a/shared/ui/status-badge.module.css b/shared/ui/status-badge.module.css index 52c2f91b..bce44114 100644 --- a/shared/ui/status-badge.module.css +++ b/shared/ui/status-badge.module.css @@ -35,3 +35,18 @@ background: var(--color-coral); color: var(--color-white); } + +.statusBadge[data-status='new'] { + background: var(--color-cream); + color: var(--color-green-dark); +} + +.statusBadge[data-status='in_progress'] { + background: var(--color-lila); + color: var(--color-white); +} + +.statusBadge[data-status='completed'] { + background: var(--color-green-light); + color: var(--color-green-dark); +} diff --git a/shared/ui/tag-list.tsx b/shared/ui/tag-list.tsx index d83b2177..2af643ad 100644 --- a/shared/ui/tag-list.tsx +++ b/shared/ui/tag-list.tsx @@ -1,13 +1,14 @@ 'use client'; import { useState, type KeyboardEvent } from 'react'; +import { useDictionary } from '@/shared/i18n/dictionary-context'; import styles from './tag-list.module.css'; interface TagListProps { label: string; placeholder?: string; - addLabel?: string; - emptyLabel?: string; + addLabel: string; + emptyLabel: string; value: string[] | null; onChange: (value: string[] | null) => void; } @@ -15,11 +16,12 @@ interface TagListProps { export function TagList({ label, placeholder, - addLabel = '+ Añadir', - emptyLabel = 'Aún no has añadido ninguna etiqueta.', + addLabel, + emptyLabel, value, onChange, }: TagListProps) { + const dict = useDictionary(); const [inputValue, setInputValue] = useState(''); const tags = value ?? []; @@ -91,9 +93,9 @@ export function TagList({ type="button" className={styles.removeBtn} onClick={() => removeTag(i)} - aria-label={`Eliminar etiqueta ${tag}`} + aria-label={`${dict.common.removeFromCart ?? 'Remove'} ${tag}`} > - × + {'\u00D7'} ))} diff --git a/tests/integration/orders.integration.test.ts b/tests/integration/orders.integration.test.ts index 84375dd0..10f30fce 100644 --- a/tests/integration/orders.integration.test.ts +++ b/tests/integration/orders.integration.test.ts @@ -46,6 +46,7 @@ describe('Orders Module - Integration Tests', () => { quantity: 1, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, ], }; diff --git a/tests/integration/orders/prisma-order-repository.integration.test.ts b/tests/integration/orders/prisma-order-repository.integration.test.ts index 949c0461..7dd5599b 100644 --- a/tests/integration/orders/prisma-order-repository.integration.test.ts +++ b/tests/integration/orders/prisma-order-repository.integration.test.ts @@ -168,6 +168,7 @@ describe('PrismaOrderRepository — Integration', () => { quantity: 1, customizationIdList: ['cust-legacy'], customizationSnapshot: null, + unitPrice: 0, }, ], }), @@ -220,6 +221,7 @@ describe('PrismaOrderRepository — Integration', () => { quantity: 1, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, { id: 'item-invalid-scalar', @@ -228,6 +230,7 @@ describe('PrismaOrderRepository — Integration', () => { quantity: 1, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, ], }), @@ -280,6 +283,7 @@ describe('PrismaOrderRepository — Integration', () => { productId: 'prod-order-2', quantity: 2, customizationIdList: [], + unitPrice: 0, customizationSnapshot: [ { id: 'cust-int-1', @@ -297,6 +301,7 @@ describe('PrismaOrderRepository — Integration', () => { productId: 'prod-order-2', quantity: 1, customizationIdList: [], + unitPrice: 0, customizationSnapshot: [ { id: 'cust-int-2', @@ -377,6 +382,7 @@ describe('PrismaOrderRepository — Integration', () => { productId: 'prod-order-2b', quantity: 1, customizationIdList: ['cust-order-1'], + unitPrice: 0, customizationSnapshot: [ { id: 'cust-order-1', @@ -580,6 +586,7 @@ describe('PrismaOrderRepository — Integration', () => { quantity: 3, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, ]; diff --git a/tests/unit/app/[locale]/cart/cart-view-customization.test.tsx b/tests/unit/app/[locale]/cart/cart-view-customization.test.tsx index 891d1c58..c2d336ca 100644 --- a/tests/unit/app/[locale]/cart/cart-view-customization.test.tsx +++ b/tests/unit/app/[locale]/cart/cart-view-customization.test.tsx @@ -43,6 +43,8 @@ describe('CartView customization links', () => { decreaseQuantity: 'Reducir cantidad', increaseQuantity: 'Aumentar cantidad', customizationEditFromCart: 'Editar personalización', + customizationDesignImageAlt: 'Diseño personalizado', + price: 'Precio', }; render( diff --git a/tests/unit/app/[locale]/checkout/page.test.tsx b/tests/unit/app/[locale]/checkout/page.test.tsx index 5bfe4637..649321db 100644 --- a/tests/unit/app/[locale]/checkout/page.test.tsx +++ b/tests/unit/app/[locale]/checkout/page.test.tsx @@ -21,6 +21,8 @@ const mocks = vi.hoisted(() => { }; }); +vi.mock('server-only', () => ({})); + vi.mock('next/navigation', () => ({ redirect: vi.fn(), })); diff --git a/tests/unit/app/[locale]/orders/[orderId]/page.test.tsx b/tests/unit/app/[locale]/orders/[orderId]/page.test.tsx index 0d06beea..d5065e5b 100644 --- a/tests/unit/app/[locale]/orders/[orderId]/page.test.tsx +++ b/tests/unit/app/[locale]/orders/[orderId]/page.test.tsx @@ -25,10 +25,19 @@ vi.mock('@/shared/infrastructure/auth-options', () => ({ vi.mock('@/shared/i18n/get-dictionary', () => ({ getDictionary: vi.fn().mockResolvedValue({ + common: { + customizationSize: 'Size', + customizationColor: 'Color', + customizationText: 'Text', + }, orders: { status: 'Status', total: 'Total', retryPayment: 'Retry payment', + myOrders: 'My orders', + date: 'Date', + items: 'Items', + noItems: 'No items in this order', }, }), })); @@ -67,7 +76,7 @@ describe('OrderDetailPage', () => { render(element); expect( - screen.getByRole('heading', { name: /order-1/i }), + screen.getByRole('heading', { name: 'My orders' }), ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Retry payment' }), diff --git a/tests/unit/app/[locale]/orders/page.test.tsx b/tests/unit/app/[locale]/orders/page.test.tsx index 94e8aebd..8f12ade5 100644 --- a/tests/unit/app/[locale]/orders/page.test.tsx +++ b/tests/unit/app/[locale]/orders/page.test.tsx @@ -91,7 +91,9 @@ describe('CustomerOrdersPage', () => { sortDir: 'desc', }), ); - expect(screen.getByText('order-1')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'View order' }), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Retry payment' }), ).toBeInTheDocument(); diff --git a/tests/unit/app/[locale]/seller/orders/page.test.tsx b/tests/unit/app/[locale]/seller/orders/page.test.tsx index 798f873b..b536057f 100644 --- a/tests/unit/app/[locale]/seller/orders/page.test.tsx +++ b/tests/unit/app/[locale]/seller/orders/page.test.tsx @@ -124,7 +124,7 @@ describe('SellerOrdersPage', () => { sortDir: 'desc', }), ); - expect(screen.getByText('order-1')).toBeInTheDocument(); + expect(screen.getByText('#order-1')).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Move to in progress' }), ).toBeInTheDocument(); 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 d068faf9..0c49839a 100644 --- a/tests/unit/app/[locale]/seller/products/product-form.test.tsx +++ b/tests/unit/app/[locale]/seller/products/product-form.test.tsx @@ -47,6 +47,7 @@ describe('ProductForm', () => { tagsLabel: 'Etiquetas', tagsPlaceholder: 'etiqueta1, etiqueta2', tagsHelp: 'Separa las etiquetas por comas.', + addLabel: 'Añadir', }, }, gallery: { diff --git a/tests/unit/app/cart/cart-view-guest.test.tsx b/tests/unit/app/cart/cart-view-guest.test.tsx index 598ec47a..654c2459 100644 --- a/tests/unit/app/cart/cart-view-guest.test.tsx +++ b/tests/unit/app/cart/cart-view-guest.test.tsx @@ -61,6 +61,8 @@ describe('CartView — guest cart', () => { customizationText: 'Texto', increaseQuantity: 'Aumentar cantidad', decreaseQuantity: 'Reducir cantidad', + customizationDesignImageAlt: 'Diseño personalizado', + price: 'Precio', }; beforeEach(() => { diff --git a/tests/unit/app/cart/cart-view.test.tsx b/tests/unit/app/cart/cart-view.test.tsx index dad27bfb..e3515e4d 100644 --- a/tests/unit/app/cart/cart-view.test.tsx +++ b/tests/unit/app/cart/cart-view.test.tsx @@ -41,6 +41,8 @@ describe('CartView', () => { customizationText: 'Texto', increaseQuantity: 'Aumentar cantidad', decreaseQuantity: 'Reducir cantidad', + customizationDesignImageAlt: 'Diseño personalizado', + price: 'Precio', }; const baseItems = [ diff --git a/tests/unit/app/checkout/checkout-confirm-button.test.tsx b/tests/unit/app/checkout/checkout-confirm-button.test.tsx index 8c7d5f36..bbf0d905 100644 --- a/tests/unit/app/checkout/checkout-confirm-button.test.tsx +++ b/tests/unit/app/checkout/checkout-confirm-button.test.tsx @@ -31,9 +31,11 @@ describe('CheckoutConfirmButton', () => { vi.clearAllMocks(); }); - it('renders a "Place Order" button', () => { + it('renders a "Realizar pedido" button', () => { render(); - expect(screen.getByRole('button', { name: /place order/i })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /realizar pedido/i }), + ).toBeTruthy(); }); it('pre-fills the inline address form when an initial profile address exists', () => { @@ -49,10 +51,10 @@ describe('CheckoutConfirmButton', () => { />, ); - expect(screen.getByLabelText(/street/i)).toHaveValue('Main St 1'); - expect(screen.getByLabelText(/city/i)).toHaveValue('Madrid'); - expect(screen.getByLabelText(/postal code/i)).toHaveValue('28001'); - expect(screen.getByLabelText(/country/i)).toHaveValue('ES'); + expect(screen.getByLabelText(/calle/i)).toHaveValue('Main St 1'); + expect(screen.getByLabelText(/ciudad/i)).toHaveValue('Madrid'); + expect(screen.getByLabelText(/código postal/i)).toHaveValue('28001'); + expect(screen.getByLabelText(/país/i)).toHaveValue('ES'); }); it('shows an error and stops when the address is incomplete', async () => { @@ -73,14 +75,14 @@ describe('CheckoutConfirmButton', () => { render(); - fireEvent.change(screen.getByLabelText(/street/i), { + fireEvent.change(screen.getByLabelText(/calle/i), { target: { value: 'Main St 1' }, }); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent( - 'Complete the address to continue', + 'Completa la dirección para continuar', ); }); @@ -116,11 +118,11 @@ describe('CheckoutConfirmButton', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent( - 'Unable to save the shipping address', + 'No se pudo guardar la dirección de envío', ); }); @@ -176,7 +178,7 @@ describe('CheckoutConfirmButton', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith('/api/users/me', { @@ -236,7 +238,7 @@ describe('CheckoutConfirmButton', () => { }} />, ); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith('/api/cart/checkout/confirm', { @@ -272,10 +274,10 @@ describe('CheckoutConfirmButton', () => { }} />, ); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { - expect(screen.getByText(/price change/i)).toBeTruthy(); + expect(screen.getByText(/cambio de precio/i)).toBeTruthy(); }); }); @@ -312,13 +314,13 @@ describe('CheckoutConfirmButton', () => { }} />, ); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { - expect(screen.getByText(/price change/i)).toBeTruthy(); + expect(screen.getByText(/cambio de precio/i)).toBeTruthy(); }); - fireEvent.click(screen.getByRole('button', { name: /accept/i })); + fireEvent.click(screen.getByRole('button', { name: /aceptar/i })); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith('/api/cart/checkout/confirm', { @@ -365,7 +367,7 @@ describe('CheckoutConfirmButton', () => { }} />, ); - fireEvent.click(screen.getByRole('button', { name: /place order/i })); + fireEvent.click(screen.getByRole('button', { name: /realizar pedido/i })); await waitFor(() => { expect(mockClearCart).toHaveBeenCalled(); diff --git a/tests/unit/modules/orders/application/assign-to-production-use-case.test.ts b/tests/unit/modules/orders/application/assign-to-production-use-case.test.ts index 3dbead06..61a0acc2 100644 --- a/tests/unit/modules/orders/application/assign-to-production-use-case.test.ts +++ b/tests/unit/modules/orders/application/assign-to-production-use-case.test.ts @@ -69,6 +69,7 @@ describe('AssignToProductionUseCase', () => { quantity: 2, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, { id: 'item-2', @@ -77,6 +78,7 @@ describe('AssignToProductionUseCase', () => { quantity: 1, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, ], }; diff --git a/tests/unit/modules/orders/application/mark-as-paid-use-case.test.ts b/tests/unit/modules/orders/application/mark-as-paid-use-case.test.ts index e59bc282..a7b5aaf1 100644 --- a/tests/unit/modules/orders/application/mark-as-paid-use-case.test.ts +++ b/tests/unit/modules/orders/application/mark-as-paid-use-case.test.ts @@ -70,6 +70,7 @@ describe('MarkAsPaidUseCase', () => { quantity: 2, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, { id: 'item-2', @@ -78,6 +79,7 @@ describe('MarkAsPaidUseCase', () => { quantity: 1, customizationIdList: [], customizationSnapshot: null, + unitPrice: 0, }, ], }; diff --git a/tests/unit/modules/presentation/components/cart-popup.test.tsx b/tests/unit/modules/presentation/components/cart-popup.test.tsx index 36647cde..345480e9 100644 --- a/tests/unit/modules/presentation/components/cart-popup.test.tsx +++ b/tests/unit/modules/presentation/components/cart-popup.test.tsx @@ -56,6 +56,7 @@ function renderPopup() { unknownSeller: 'Unknown Seller', increaseQuantity: 'Increase quantity', decreaseQuantity: 'Decrease quantity', + close: 'Close', }} /> , diff --git a/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx b/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx index 12a75ad0..6ca7e0a9 100644 --- a/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx +++ b/tests/unit/modules/presentation/components/user-menu-dropdown.test.tsx @@ -34,28 +34,6 @@ describe('UserMenuDropdown component', () => { expect(screen.getByRole('menu')).toBeInTheDocument(); }); - it('shows 3 menu items: profile, changePassword, closeSession', () => { - render(); - - const trigger = screen.getByRole('button', { name: /menu/i }); - fireEvent.click(trigger); - - expect( - screen.getByRole('menuitem', { name: /mi perfil/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole('menuitem', { name: /editar contraseña/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole('menuitem', { name: /cerrar sesión/i }), - ).toBeInTheDocument(); - - // Delete account should NOT be in the dropdown - expect( - screen.queryByRole('menuitem', { name: /eliminar cuenta/i }), - ).toBeNull(); - }); - it('click outside closes dropdown', () => { render(
diff --git a/verify-report-cart-pr1.md b/verify-report-cart-pr1.md deleted file mode 100644 index b6637c96..00000000 --- a/verify-report-cart-pr1.md +++ /dev/null @@ -1,155 +0,0 @@ -# Verification Report — Cart Module PR 1 (Foundation) - -## Metadata - -| Field | Value | -| -------------- | --------------------------- | -| Change | cart-module | -| Phase | PR 1 of 3 — Foundation | -| Mode | interactive | -| Strict TDD | ACTIVE | -| Artifact store | engram (project: 728-store) | -| Review budget | 800 lines | - -## Completeness Table - -| Source | Artifact | Status | -| -------------- | --------------------------------------- | ------ | -| Spec | `sdd/cart-module/spec` (#288) | Read | -| Design | `sdd/cart-module/design` (#289) | Read | -| Tasks | `sdd/cart-module/tasks` (#290) | Read | -| Apply Progress | `sdd/cart-module/apply-progress` (#291) | Read | - -PR 1 scope: Prisma schema + migration, domain value objects, domain entities, domain errors, domain ports, in-memory test double. - -## Build / Tests / Coverage Evidence - -| Command | Result | -| ----------------------------------------------------------------------------------------- | ----------------------------- | -| `npx tsc --noEmit` | ✅ No errors | -| `npx vitest run tests/unit/modules/cart tests/doubles/memory-cart-repository.ts` | ✅ 62/62 passed | -| `npx eslint modules/cart tests/unit/modules/cart tests/doubles/memory-cart-repository.ts` | ✅ No errors/warnings | -| `npx prisma migrate status` | ✅ Database schema up to date | - -Coverage tool (`@vitest/coverage-v8`) is not installed; per-file coverage analysis was skipped. - -## Spec Compliance Matrix - -| Requirement | Scenario / Rule | Implementation Evidence | Test Evidence | Status | -| --------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ | -| REQ-CART-001 Cart Entity | Fields id, userId, status, createdAt, updatedAt, items | `prisma/schema.prisma` Cart model; `modules/cart/domain/entities/cart.ts` | `memory-cart-repository.test.ts` | ✅ PASS | -| REQ-CART-001 | User has at most one ACTIVE cart | Index `@@index([userId, status])` + `findActiveByUserId` | `memory-cart-repository.test.ts` L60-80 | ⚠️ PARTIAL — DB allows duplicates; app must enforce (see Warnings) | -| REQ-CART-001 | Auto-create on first add | Out of PR 1 scope (use case in PR 2) | — | ⏸️ SKIPPED | -| REQ-CART-001 | Reject mutations on checked-out cart | Out of PR 1 scope (use case in PR 2) | — | ⏸️ SKIPPED | -| REQ-CART-002 CartItem Entity | Fields incl. quantity 1..99, unitPriceSnapshot, customization | `prisma/schema.prisma` CartItem model; `modules/cart/domain/entities/cart-item.ts` | `memory-cart-repository.test.ts` | ✅ PASS | -| REQ-CART-002 | Quantity range 1..99 | `modules/cart/domain/value-objects/quantity.ts` | `quantity.test.ts` L18-54 | ✅ PASS | -| REQ-CART-002 | Separate row per customization variant | Out of PR 1 scope (use case in PR 2) | — | ⏸️ SKIPPED | -| REQ-CART-003 CartStatus | ACTIVE / CHECKED_OUT only | `modules/cart/domain/value-objects/cart-status.ts` | `memory-cart-repository.test.ts` L70-80 | ✅ PASS | -| REQ-CART-003 | Transition ACTIVE → CHECKED_OUT via checkout | `CartRepository.markCheckedOut` exists | `memory-cart-repository.test.ts` L135-154 | ✅ PASS | -| REQ-CART-021 PrismaCartRepository | Index `@@index([userId, status])` | `prisma/schema.prisma` L208; migration L37 | `schema-product-domain.test.ts` (existing) | ✅ PASS | -| REQ-CART-021 | Transactions via `prisma.$transaction` | Out of PR 1 scope (adapter in PR 2) | — | ⏸️ SKIPPED | - -## Correctness Table - -| Area | Findings | -| ------------- | --------------------------------------------------------------------------- | -| Prisma schema | Matches spec: Cart, CartItem, CartStatus enum, indexes, User back-relation. | -| Value objects | `CartId`, `CartItemId`, `Quantity`, `CartStatus` implemented and tested. | -| Domain errors | All specified error types present and extend `AppError` correctly. | -| Ports | `CartRepository`, `ProductRepository`, `ProductSnapshot` defined. | -| Test double | `MemoryCartRepository` implements port 1:1 and is fully tested. | - -## Design Coherence Table - -| Design Item | Design Spec | Implementation | Status | -| ------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------- | -------------------------------------- | -| Folder structure | `modules/cart/domain/...` | Matches | ✅ | -| CartEntity interface | id, userId, status, items, createdAt, updatedAt | Matches | ✅ | -| CartItemEntity interface | incl. unitPriceSnapshot | Uses `Money` instead of `number` | ⚠️ Deviation — see Warnings | -| CartRepository port | findByUserId, save, markCheckedOut, deleteItem, findItemById | Adds `findById`, `findItemsByCartId`; renames findByUserId → `findActiveByUserId` | ⚠️ Deviation — aligns better with spec | -| ProductRepository port | findById, findByIds | Matches | ✅ | -| ProductSnapshot | id, basePrice, sellerId | Matches | ✅ | -| MemoryCartRepository | Mirror production port 1:1 | Matches | ✅ | - -## TDD Compliance - -| Check | Result | Details | -| ----------------------------- | ------------------ | --------------------------------------------------------------------------------------------------- | -| TDD Evidence reported | ✅ Found | Apply-progress contains TDD Cycle Evidence table | -| All tasks have tests | ✅ 5/5 task groups | Test files exist for CartId, CartItemId, Quantity, errors, MemoryCartRepository | -| RED confirmed (tests exist) | ✅ 5/5 | All listed test files present in repo | -| GREEN confirmed (tests pass) | ✅ 62/62 | Executed and passed | -| Triangulation adequate | ✅ | Quantity 18 cases, MemoryRepo 16 cases, errors 12 cases, IDs 8 cases each | -| Safety Net for modified files | ⚠️ N/A | Prisma schema modification has no automated safety-net test; new domain files are new, not modified | - -**TDD Compliance**: 5/6 checks passed (safety net informational only). - -## Test Layer Distribution - -| Layer | Tests | Files | Tools | -| ----------- | ------ | ----- | ------ | -| Unit | 62 | 5 | vitest | -| Integration | 0 | 0 | — | -| E2E | 0 | 0 | — | -| **Total** | **62** | **5** | | - -All PR 1 tests are pure unit tests with no DB, HTTP, or browser dependencies, consistent with the foundation scope. - -## Changed File Coverage - -Coverage analysis skipped — `@vitest/coverage-v8` is not installed. - -## Assertion Quality - -**Assertion quality**: ✅ All assertions verify real behavior. - -No tautologies, ghost loops, type-only assertions, or smoke-test-only cases were found in the new test files. - -## Quality Metrics - -| Tool | Result | -| ------------------ | ------------------------------------------- | -| Linter (eslint) | ✅ No errors/warnings on changed cart files | -| Type Checker (tsc) | ✅ No errors project-wide | - -## Issues - -### CRITICAL Issues - -None. - -### WARNING Issues - -1. **DB-level uniqueness of ACTIVE cart not enforced** - - Spec REQ-CART-001 states a user MUST have at most one ACTIVE cart at a time. - - The Prisma schema only defines `@@index([userId, status])`, not a unique index/constraint. - - Duplicate ACTIVE carts are possible at the database level; application/use-case logic in PR 2 must enforce the rule consistently. - -2. **Design deviation: `CartItemEntity.unitPriceSnapshot` uses `Money` instead of `number`** - - The design contract explicitly lists `unitPriceSnapshot: number`. - - The implementation uses `Money` from `shared/kernel`. - - This is more type-safe and matches the spec's "Decimal / EUR" intent, but it is a contract deviation that PR 2 adapters must handle correctly when mapping to/from Prisma `Decimal`. - -3. **Design deviation: `CartRepository` method set differs from design contract** - - Design listed `findByUserId`; implementation provides `findActiveByUserId` plus extra `findById` and `findItemsByCartId`. - - The deviation improves alignment with spec (active-cart lookup) and testability, but consumers written against the design contract may need adjustment. - -### SUGGESTIONS - -1. **Consider expanding `ProductSnapshot` for presentation needs** - - Current `ProductSnapshot` only exposes `id`, `basePrice`, `sellerId`. - - The spec's `CartItemDTO` requires `productName`, `productImageUrl`, and `sellerName`. - - PR 2 should either extend the snapshot or add a separate product-name lookup port to avoid ad-hoc cross-module imports. - -2. **Add DB-level partial unique constraint when feasible** - - If the target PostgreSQL version supports partial unique indexes, consider `@@unique([userId, status])` with a filtered index on `status = 'ACTIVE'` to make the "one active cart" rule database-enforced. - -3. **Document entity invariant enforcement strategy** - - `CartEntity` and `CartItemEntity` are pure interfaces, so invariants such as "checked-out cart rejects mutations" must live in use cases. - - Ensure PR 2 use-case tests explicitly cover these invariants. - -## Verdict - -**PASS WITH WARNINGS** - -PR 1 foundation is complete, type-safe, lint-clean, and all 62 new tests pass. The Prisma schema, domain value objects, entities, errors, ports, and test double are in place and align with the spec intent. The warnings are design/contract deviations and a missing DB-level uniqueness guarantee that should be addressed or consciously accepted before PR 2 begins. diff --git a/verify-report-customizations-pr4.md b/verify-report-customizations-pr4.md deleted file mode 100644 index 675c695a..00000000 --- a/verify-report-customizations-pr4.md +++ /dev/null @@ -1,167 +0,0 @@ -## Verification Report — Customizations Module (PR1-PR4) - -**Date**: 2026-06-29 -**Branch**: `feat/customizations-pr4-cleanup-docs` -**Scope**: Full customizations chain functional verification -**TypeScript**: ✅ Clean (`tsc --noEmit` passes) -**Unit Tests**: ✅ 1191 passed, 0 failed, 0 skipped -**Mode**: Standard verify (no Strict TDD artifacts found) - ---- - -### Final Verdict: **PARTIAL — PASS WITH WARNINGS** - -The domain model, application use cases, infrastructure repository, and cart/orders integration are solid. All unit tests pass. However, **the customizations module has zero API routes**, which is a blocking gap for frontend management. Additionally, guest cart migration silently discards customization data. - ---- - -### Executive Summary - -| Area | Status | Detail | -| ------------------------------ | ----------- | ----------------------------------------------------------------- | -| Domain model + VO | ✅ PASS | `CustomizationEntity` clean, `CustomizationOptions` validated | -| Products ownership leak | ✅ PASS | No `customizations` field on `ProductEntity` | -| Application use cases | ✅ PASS | Create/update/delete/getById/getByIds all tested | -| Infrastructure repository | ✅ PASS | `PrismaCustomizationRepository` maps correctly | -| Cart integration (add-to-cart) | ✅ PASS | Customization ID validation, variant merge/split, sorted IDs | -| Checkout integration | ✅ PASS | Snapshots frozen in event payload, idempotent + transactional | -| Orders integration | ✅ PASS | `HandleCartCheckedOut` groups by seller, preserves customizations | -| Guest cart migration | ⚠️ WARNING | Customization data silently lost (see finding #2) | -| API routes for customizations | ❌ CRITICAL | **Zero customizations API routes exist** | -| Integration tests | ⚠️ WARNING | No DB-level integration tests for cart-customization flow | - ---- - -### Functional Coverage Matrix - -| # | Verification Area | Verdict | Evidence | -| --- | ----------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `CustomizationEntity` shape — no `sellerId` | ✅ PASS | `domain/entities/customization.ts` has 6 fields (id, productId, text, color, size, imageUrl, createdAt). No `sellerId`. | -| 2 | `CustomizationOptions` validation: text ≤500, color ≤50, size ≤50, URL rules | ✅ PASS | 15 VO tests + 10 domain tests covering all bounds and edge cases. | -| 3 | `CustomizationOptions.equals()` | ✅ PASS | 4 equals tests: identical, different text, non-CustomizationOptions, empty. | -| 4 | `ProductEntity` has no `customizations` field | ✅ PASS | `modules/products/domain/entities/product.ts` — 0 customization references. `mapper.test.ts` L:98 `expect(result).not.toHaveProperty('customizations')`. | -| 5 | No `customizations` in products infrastructure mapper | ✅ PASS | Grep: zero results in `modules/products/infrastructure`. | -| 6 | `CreateCustomization` — product validation + VO validation + persist | ✅ PASS | 6 tests: all fields, no optionals, text>500, blank color, invalid URL, persist, non-existent product. | -| 7 | `UpdateCustomization` — ownership gate, re-validation, partial update | ✅ PASS | 5 tests: owner update, non-owner reject, non-existent, re-validate, partial fields preserved. | -| 8 | `DeleteCustomization` — ownership gate, in-use protection | ✅ PASS | 4 tests: delete succeeds, non-owner reject, in-use reject, non-existent. | -| 9 | `GetCustomizationById` — found/not-found | ✅ PASS | 2 tests: found returns entity, not found returns null. | -| 10 | `GetCustomizationByIds` — Map result, partial missing, empty input | ✅ PASS | 4 tests: Map keyed by id, partial results, all missing, empty input. | -| 11 | `PrismaCustomizationRepository` — save (upsert), findById, findByIds, findByProductId, findBySellerId, delete, isReferencedByOrders | ✅ PASS | All methods present. `findBySellerId` uses `product: { sellerId }` relation. `isReferencedByOrders` uses `has:` on `customizationIdList`. | -| 12 | Prisma schema: `Customization` model with FK to `Product`, `@@index([productId])` | ✅ PASS | `schema.prisma` L:83-94. `OrderLineItem.customizationIdList String[]`, `CartItem.customizationIdList String[]`. | -| 13 | `AddItemToCart` validates customization IDs exist + belong to product | ✅ PASS | 7 customization tests: non-existent ID, wrong product, partial miss, valid IDs, multiple IDs, sorted storage, deduplicate. | -| 14 | Same variant merge uses sorted `customizationIdList` | ✅ PASS | Test "merges regardless of order" (sorted comparison). Test "does not merge when customizationIdList differs". | -| 15 | `CheckoutCart` freezes customization snapshots in `CART_CHECKED_OUT` event | ✅ PASS | 5 customization payload tests: snapshot present, null when empty, omit deleted IDs, empty array when all deleted. | -| 16 | `CheckoutCart` transactional outbox pattern | ✅ PASS | 2 atomicity tests: single run() callback, abort prevents all writes. | -| 17 | `HandleCartCheckedOut` resolves customizations for order line items | ✅ PASS | Uses `customizationLookup.findByIds()`, idempotent by cartId. | -| 18 | Cart guest/user merge not regressed by customization changes | ⚠️ PASS | Merge tests pass but guest customizations silently lost (see WARNING #2). | -| 19 | Products pages/API still work without `ProductEntity.customizations` | ✅ PASS | `ProductEntity` clean. Products API route test passes (10 tests). No customization references in product API. | -| 20 | API routes for customization management (CRUD) | ❌ FAIL | **Zero routes under `app/api/custom*/`. No way for frontend to manage customizations.** | - ---- - -### Frontend Readiness: **NOT READY** - -**Missing contracts/routes/schemas:** - -| Missing Item | Priority | Detail | -| --------------------------------- | -------- | -------------------------------------------------- | -| `POST /api/customizations` | CRITICAL | Sellers cannot create customizations | -| `PUT /api/customizations/[id]` | CRITICAL | Sellers cannot update customizations | -| `DELETE /api/customizations/[id]` | CRITICAL | Sellers cannot delete customizations | -| `GET /api/customizations` | HIGH | Sellers cannot list their customizations | -| `GET /api/customizations/[id]` | MEDIUM | Single customization lookup | -| Request/response Zod schemas | CRITICAL | No `customization-schemas.ts` exists | -| Seller authorization middleware | CRITICAL | Only the owner seller should manage customizations | - -**What frontend CAN consume today:** - -- Cart items include resolved `customizations: [{ id, text, color, size, imageUrl }]` in `GET /api/cart` and `POST /api/cart/items` -- Checkout returns customization data in the event payload -- Order detail endpoints carry `customizationIdList` + `customizationSnapshot` - ---- - -### Blocking Findings (CRITICAL) - -| # | Severity | File(s) | Evidence | -| --- | ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | **CRITICAL** | `app/api/` | **No customizations API routes exist**. Glob `app/api/custom*/**/*` returns zero files. The domain, application, and infrastructure layers are fully built and tested but have no HTTP presentation layer. Sellers cannot create, update, delete, or list customizations. | -| 2 | **CRITICAL** | `modules/cart/application/migrate-guest-cart.ts:228` | **Guest cart migration loses customization data**. The `buildItem()` method always sets `customizationIdList: []`. Guest items carry `customizationText/Color/Size/ImageUrl` fields per the `GuestCartItem` interface, but these are never resolved to actual customization IDs. The `isSameVariant()` helper at line 247-260 compares the server's `customizationIdList` against `[]` for guest items, meaning all same-product guest items merge regardless of customization differences. | -| 3 | **CRITICAL** | `app/api/cart/migrate/route.ts:111-116` | **Migration route returns `customization: { text: null, color: null, size: null, imageUrl: null }`** — hardcoded nulls, no resolution of customization snapshots. Cart GET and cart items POST both resolve customizations, but the migrate route does not. | - -### Non-Blocking Findings (WARNING) - -| # | Severity | File(s) | Evidence | -| --- | -------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 4 | WARNING | `composition-root/container.ts:277-279` | **Single adapter bridges two different port types**. Cart's `CustomizationLookupPort` returns `CustomizationSnapshot[]` (with `productId`), Orders' `CustomizationLookupPort` returns `CustomizationLookupSnapshot[]` (with `productId`). Both are structurally compatible but use module-local types. Intentional design but worth documenting. | -| 5 | WARNING | `tests/unit/shared/kernel/customization-options.test.ts` | **Stale test location**. This test imports from `@/modules/customizations/...` (correct) but lives under `tests/unit/shared/kernel/` where no source file for `CustomizationOptions` exists. Should be co-located with the module. | -| 6 | WARNING | `modules/customizations/application/__tests__/` | **`findBySellerId` not unit-tested**. Both fake repositories return `[]` with comment "sellerId is derived from Product — not testable in this unit-level fake". Coverage relies on integration tests that don't exist yet. | -| 7 | WARNING | `modules/customizations/` | **No integration or E2E tests for customizations**. The full customization lifecycle (create → add-to-cart → checkout → order snapshot) is untested against a real database. | - -### Suggestions - -| # | Detail | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| S1 | Add `POST/PUT/DELETE/GET /api/customizations` routes with Zod schemas and seller-ownership middleware. | -| S2 | Implement guest→user customization ID resolution in `MigrateGuestCart`: lookup customizations by (productId + text + color + size + imageUrl) or accept that guest customizations are discarded with explicit UX handling. | -| S3 | Add DB-level integration tests for the full customization chain. | -| S4 | Move `tests/unit/shared/kernel/customization-options.test.ts` → co-locate with the customizations module tests. | -| S5 | Add a test for `PrismaCustomizationRepository.findBySellerId` using an in-memory SQLite or a test double that provides product relations. | - ---- - -### Tests Run - -``` -npx vitest run --config vitest.config.ts - 120 test files - 1191 tests passed - 0 failures - 0 skipped - -npx tsc --noEmit - No errors - -npx vitest run --config vitest.config.ts modules/customizations - 3 test files - 47 tests passed - 0 failures -``` - -Key test files relevant to this verification: - -- `modules/customizations/domain/__tests__/customization-domain.test.ts` — 25 tests (entity shape, VO validation, equals) -- `modules/customizations/application/__tests__/customization-use-cases.test.ts` — 16 tests (create/update/delete) -- `modules/customizations/application/__tests__/customization-queries.test.ts` — 6 tests (getById/getByIds) -- `tests/unit/modules/cart/application/add-item-to-cart.test.ts` — 23 tests (customization validation, merge/split) -- `tests/unit/modules/cart/application/checkout-cart.test.ts` — 21 tests (snapshot freezes, atomicity) -- `tests/unit/modules/orders/application/handle-cart-checked-out.test.ts` — 12 tests (order creation with customizations) -- `tests/unit/modules/cart/application/migrate-guest-cart.test.ts` — 10 tests (merge strategies) -- `prisma/__tests__/schema-shape.test.ts` — 16 tests (no denormalized customization columns) -- `tests/unit/modules/products/infrastructure/mapper.test.ts` — no `customizations` property on ProductEntity - ---- - -### Integration Assessment - -| Integration Point | Status | Notes | -| ---------------------------------------------- | ------ | ------------------------------------------------- | -| Cart → Customizations (add-to-cart) | ✅ | Port + adapter pattern, tested with fakes | -| Cart → Customizations (checkout) | ✅ | Snapshots frozen in CART_CHECKED_OUT payload | -| Orders → Customizations (HandleCartCheckedOut) | ✅ | Resolves via lookup port, stores on OrderLineItem | -| Cart → Customizations (migrate-guest) | ❌ | Customization data silently lost | -| Products → Customizations (FK) | ✅ | Prisma FK with `onDelete: Cascade` | -| Products → Customizations (entity) | ✅ | No `customizations` field leak | - ---- - -### Recommended Next Steps - -1. **Implement customizations API routes** (P0) — `POST/PUT/DELETE/GET /api/customizations` with: - - Zod request/response schemas - - Seller authorization (only product owner can manage) - - Proper error mapping for `CustomizationInUseError` (409), `CustomizationForbiddenError` (403) -2. **Fix guest cart migration** (P0) — Either resolve guest customization text/color/size/imageUrl to actual IDs or clearly document the data loss as a known limitation with UX handling. -3. **Add integration tests** (P1) — Test the full chain against a test database. -4. **Co-locate stale test** (P2) — Move `tests/unit/shared/kernel/customization-options.test.ts` to the customizations module. -5. **Frontend phase** — Can begin consuming `GET /api/cart` responses (which already include resolved customizations) but frontend management of customizations MUST wait for #1.