diff --git a/.agents/skills/writing-spec/SKILL.md b/.agents/skills/writing-spec/SKILL.md index af90692..8ba1f0d 100644 --- a/.agents/skills/writing-spec/SKILL.md +++ b/.agents/skills/writing-spec/SKILL.md @@ -38,7 +38,7 @@ Collaborate on sections 4-6 of the template. 2. For non-trivial features, propose **two plausible designs** with tradeoffs. Let the developer choose. Capture the winner and rationale in **Design Decisions** 3. Draft the **Boundaries** section using the three-tier system: - ✅ **Always** — proceed without asking (e.g., create files in the feature directory) - - ⚠️ **Ask first** — needs approval (e.g., modify API contracts, add dependencies, change schema) + - ⚠️ **Ask first** — needs approval (e.g., modify API contracts, change schema, create shared utilities) - 🚫 **Never** — hard stops (e.g., modify core auth, remove tests, commit secrets) 4. Present for review diff --git a/public/locales/en-GB/auth.json b/public/locales/en-GB/auth.json new file mode 100644 index 0000000..408242c --- /dev/null +++ b/public/locales/en-GB/auth.json @@ -0,0 +1,17 @@ +{ + "sign-in": { + "form": { + "header": "Sign in to your account", + "description": "to enjoy all of our cool features ✌️", + "username": "Username", + "password": "Password", + "remember-me": "Remember me", + "forgot-password": "Forgot password?", + "sign-in": "Sign in" + }, + "notifications": { + "success": "Successfully signed in!", + "error": "Failed to sign in. Please check your credentials and try again." + } + } +} diff --git a/public/locales/en-GB/carts.json b/public/locales/en-GB/carts.json new file mode 100644 index 0000000..bac66a8 --- /dev/null +++ b/public/locales/en-GB/carts.json @@ -0,0 +1,93 @@ +{ + "list": { + "subtotal": "Subtotal", + "shipping-info": "Shipping and taxes will be calculated at checkout.", + "checkout": "Checkout", + "continue-shopping": "Continue shopping" + }, + "item": { + "remove": "Remove", + "in-stock": "In stock", + "continue-shopping": "Continue shopping" + }, + "remove-product": { + "notifications": { + "title": "Cart", + "decrement-success": "Product removed.", + "remove-all-success": "Product removed from your cart.", + "product-not-found-error": "Product not found in your cart. It may have already been removed.", + "error": "Something went wrong. Please try again or contact us." + }, + "quantity-controls": { + "decrement": "Decrease quantity", + "increment": "Increase quantity" + }, + "dialog": { + "cancel": "Cancel", + "decrement": { + "title": "Remove last item", + "message": "This is the last unit. Are you sure you want to remove it?", + "confirm": "Yes, remove" + }, + "remove-all": { + "title": "Remove product", + "message": "Are you sure you want to remove all units of this product?", + "confirm": "Yes, remove all" + } + } + }, + "add-to-cart": { + "button": "Add to cart", + "notifications": { + "title": "New product", + "success": "A product has been successfully added to your cart.", + "error": "Something went wrong with adding a product to a cart. Pleas try again or contact us.", + "unknown-product-error": "Product doesn't exist. It may be unavailable or removed from the store.", + "product-not-available-error": "Product is not available. It may be out of stock or removed from the store.", + "not-authenticated": "Please log in in order to add products." + }, + "dialog": { + "title": "New product in the cart", + "message": "Wonderful! You have already added a new product to your cart.", + "success-message": "Success! Do you want to:", + "go-to-cart": "Go to cart", + "continue-shopping": "Continue shopping" + } + }, + "clear-cart": { + "button": "Clear cart", + "notifications": { + "title": "Clear cart", + "success": "Your cart has been successfully cleared.", + "error": "Something went wrong with clearing your cart. Pleas try again or contact us." + }, + "dialog": { + "title": "Clear cart", + "message": "Are you sure? You can't undo this action afterwards.", + "cancel": "Cancel", + "confirm": "Yes, clear cart" + } + }, + "checkout": { + "button": "Checkout", + "dialog": { + "title": "Checkout" + }, + "form": { + "full-name": "Full Name", + "address": "Address", + "payment-method": "Payment Method", + "submit": "Complete Order", + "payment-methods": { + "blik": "Blik", + "card": "Credit Card", + "paypal": "PayPal" + } + }, + "notifications": { + "title": "Checkout", + "success": "You have successfully purchased all selected products.", + "error": "Something went wrong with finalizing a transaction. Pleas try again or contact us." + } + } +} diff --git a/public/locales/en-GB/marketing.json b/public/locales/en-GB/marketing.json new file mode 100644 index 0000000..a0c882b --- /dev/null +++ b/public/locales/en-GB/marketing.json @@ -0,0 +1,12 @@ +{ + "rating": { + "tooltip": "Average customer rating: {rating}", + "see-reviews": "See all {{number}} reviews", + "notifications": { + "title": "Product rating", + "not-authenticated": "You have to log in to rate the product", + "success": "Thank you! Your rating has been submitted.", + "error": "Something went wrong with submitting your rating. Please try again or contact us." + } + } +} diff --git a/public/locales/en-GB/pages.json b/public/locales/en-GB/pages.json new file mode 100644 index 0000000..9512230 --- /dev/null +++ b/public/locales/en-GB/pages.json @@ -0,0 +1,18 @@ +{ + "cart": { + "title": "List of selected products", + "description": "These are all products that you yet chose (updated {{time}})." + }, + "products": { + "title": "Products list", + "description": "Explore what we have in the store for you.", + "more-filters": "More filters", + "load-more": { + "no-more": "No more products", + "show-more": "Show more products" + } + }, + "product": { + "back-to-list": "Back to products' list" + } +} diff --git a/public/locales/en-GB/products.json b/public/locales/en-GB/products.json new file mode 100644 index 0000000..f537062 --- /dev/null +++ b/public/locales/en-GB/products.json @@ -0,0 +1,24 @@ +{ + "categories": { + "clothing": "Clothing", + "jewelery": "Jewelery", + "electronics": "Electronics" + }, + "not-found": { + "heading": "Product doesn't exist", + "description": "Probably this product is no more for a sale or you just got here by accident. If you think there is something wrong on our side, please contact us!", + "back-to-list": "Back to products' list" + }, + "details": { + "collection": "A part of our {{category}} collection.", + "back-to-list": "Back to products' list", + "features": "Features", + "features-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + "care": "Care", + "care-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + "shipping": "Shipping", + "shipping-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + "returns": "Returns", + "returns-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + } +} diff --git a/public/locales/en-GB/shared.json b/public/locales/en-GB/shared.json new file mode 100644 index 0000000..fdc582f --- /dev/null +++ b/public/locales/en-GB/shared.json @@ -0,0 +1,37 @@ +{ + "toast": { + "not-implemented": { + "title": "Feature not available yet", + "description": "We are working on it day and night :))" + } + }, + "form": { + "required": "Field is required.", + "select": { + "loading": "Loading…", + "noResults": "No results" + } + }, + "buttons": { + "contact-us": "Contact us!", + "reset-filters": "Reset filters" + }, + "result": { + "empty-state": { + "heading": "No results found", + "description": "Unfortunately, there is nothing for you here yet!" + }, + "not-found": { + "heading": "Page doesn't exist", + "description": "Probably you got here by accident. If you think there is something wrong on our side, please contact us!" + }, + "error": { + "heading": "Something went wrong", + "description": "It sounds like something unexpected happened right now. Please, give it a try later or, if it's urgent, contact our support team." + }, + "server-error": { + "heading": "Something went seriously wrong", + "description": "It sounds like something unexpected happened right now. Please, inform our support team about this issue ASAP!" + } + } +} diff --git a/public/locales/en-GB/translation.json b/public/locales/en-GB/translation.json deleted file mode 100644 index 505279c..0000000 --- a/public/locales/en-GB/translation.json +++ /dev/null @@ -1,180 +0,0 @@ -{ - "pages": { - "cart": { - "title": "List of selected products", - "description": "These are all products that you yet chose (updated {{time}})." - }, - "products": { - "title": "Products list", - "description": "Explore what we have in the store for you.", - "more-filters": "More filters", - "load-more": { - "no-more": "No more products", - "show-more": "Show more products" - } - }, - "product": { - "back-to-list": "Back to products' list" - } - }, - "shared": { - "toast": { - "not-implemented": { - "title": "Feature not available yet", - "description": "We are working on it day and night :))" - } - }, - "form": { - "required": "Field is required.", - "select": { - "loading": "Loading…", - "noResults": "No results" - } - }, - "buttons": { - "contact-us": "Contact us!", - "reset-filters": "Reset filters" - }, - "result": { - "empty-state": { - "heading": "No results found", - "description": "Unfortunately, there is nothing for you here yet!" - }, - "not-found": { - "heading": "Page doesn't exist", - "description": "Probably you got here by accident. If you think there is something wrong on our side, please contact us!" - }, - "error": { - "heading": "Something went wrong", - "description": "It sounds like something unexpected happened right now. Please, give it a try later or, if it's urgent, contact our support team." - }, - "server-error": { - "heading": "Something went seriously wrong", - "description": "It sounds like something unexpected happened right now. Please, inform our support team about this issue ASAP!" - } - } - }, - "features": { - "marketing": { - "rating": { - "tooltip": "Average customer rating: {rating}", - "see-reviews": "See all {{number}} reviews", - "notifications": { - "title": "Product rating", - "not-authenticated": "You have to log in to rate the product", - "success": "Thank you! Your rating has been submitted.", - "error": "Something went wrong with submitting your rating. Please try again or contact us." - } - } - }, - "products": { - "categories": { - "clothing": "Clothing", - "jewelery": "Jewelery", - "electronics": "Electronics" - }, - "not-found": { - "heading": "Product doesn't exist", - "description": "Probably this product is no more for a sale or you just got here by accident. If you think there is something wrong on our side, please contact us!", - "back-to-list": "Back to products' list" - }, - "details": { - "collection": "A part of our {{category}} collection.", - "back-to-list": "Back to products' list", - "features": "Features", - "features-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - "care": "Care", - "care-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - "shipping": "Shipping", - "shipping-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - "returns": "Returns", - "returns-content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." - } - }, - "carts": { - "list": { - "subtotal": "Subtotal", - "shipping-info": "Shipping and taxes will be calculated at checkout.", - "checkout": "Checkout", - "continue-shopping": "Continue shopping" - }, - "item": { - "quantity": "Quantity:", - "remove": "Remove", - "in-stock": "In stock", - "continue-shopping": "Continue shopping" - }, - "add-to-cart": { - "button": "Add to cart", - "notifications": { - "title": "New product", - "success": "A product has been successfully added to your cart.", - "error": "Something went wrong with adding a product to a cart. Pleas try again or contact us.", - "unknown-product-error": "Product doesn't exist. It may be unavailable or removed from the store.", - "product-not-available-error": "Product is not available. It may be out of stock or removed from the store.", - "not-authenticated": "Please log in in order to add products." - }, - "dialog": { - "title": "New product in the cart", - "message": "Wonderful! You have already added a new product to your cart.", - "success-message": "Success! Do you want to:", - "go-to-cart": "Go to cart", - "continue-shopping": "Continue shopping" - } - }, - "clear-cart": { - "button": "Clear cart", - "notifications": { - "title": "Clear cart", - "success": "Your cart has been successfully cleared.", - "error": "Something went wrong with clearing your cart. Pleas try again or contact us." - }, - "dialog": { - "title": "Clear cart", - "message": "Are you sure? You can't undo this action afterwards.", - "cancel": "Cancel", - "confirm": "Yes, clear cart" - } - }, - "checkout": { - "button": "Checkout", - "dialog": { - "title": "Checkout" - }, - "form": { - "full-name": "Full Name", - "address": "Address", - "payment-method": "Payment Method", - "submit": "Complete Order", - "payment-methods": { - "blik": "Blik", - "card": "Credit Card", - "paypal": "PayPal" - } - }, - "notifications": { - "title": "Checkout", - "success": "You have successfully purchased all selected products.", - "error": "Something went wrong with finalizing a transaction. Pleas try again or contact us." - } - } - }, - "auth": { - "sign-in": { - "form": { - "header": "Sign in to your account", - "description": "to enjoy all of our cool features ✌️", - "username": "Username", - "password": "Password", - "remember-me": "Remember me", - "forgot-password": "Forgot password?", - "sign-in": "Sign in" - }, - "notifications": { - "success": "Successfully signed in!", - "error": "Failed to sign in. Please check your credentials and try again." - } - } - } - } -} diff --git a/server/src/db/db.json b/server/src/db/db.json index 519ec4e..9701424 100644 --- a/server/src/db/db.json +++ b/server/src/db/db.json @@ -518,23 +518,19 @@ { "id": "00000000-0000-0000-0000-000000000001", "userId": 1, - "date": "2026-04-22T18:29:41.007Z", + "date": "2026-04-26T16:48:22.702Z", "products": [ { - "productId": "4f968992-1aab-49c9-8913-09405915c1c8", - "quantity": 1 - }, - { - "productId": "4f968992-1aab-49c9-8913-09405915c1c9", - "quantity": 1 + "productId": "4f968992-1aab-49c9-8913-09405915c1c0", + "quantity": 7 }, { "productId": "4f968992-1aab-49c9-8913-09405915c1c1", - "quantity": 1 + "quantity": 4 }, { - "productId": "4f968992-1aab-49c9-8913-09405915c1c4", - "quantity": 1 + "productId": "4f968992-1aab-49c9-8913-09405915c1c5", + "quantity": 4 } ] }, diff --git a/server/src/modules/carts/carts.handlers.ts b/server/src/modules/carts/carts.handlers.ts index 43a41c5..f171912 100644 --- a/server/src/modules/carts/carts.handlers.ts +++ b/server/src/modules/carts/carts.handlers.ts @@ -69,6 +69,51 @@ export async function addToCartHandler( reply.send(updated); } +export async function removeCartProductHandler( + request: FastifyRequest<{ Params: { id: string; productId: string } }>, + reply: FastifyReply +): Promise { + const db = await getDb(); + const index = db.data.carts.findIndex((c) => c.id === request.params.id); + + if (index === -1) { + reply.code(404).send({ message: "Cart not found" }); + return; + } + + const cart = db.data.carts[index]; + const productIndex = cart.products.findIndex( + (p) => p.productId === request.params.productId + ); + + if (productIndex === -1) { + reply.code(404).send({ message: "Product not found in cart" }); + return; + } + + const currentProduct = cart.products[productIndex]; + const updatedProducts = + currentProduct.quantity > 1 + ? cart.products.map((p) => + p.productId === request.params.productId + ? { ...p, quantity: p.quantity - 1 } + : p + ) + : cart.products.filter((p) => p.productId !== request.params.productId); + + const updated = { + ...cart, + products: updatedProducts, + date: new Date().toISOString(), + }; + + await db.update((data) => { + data.carts[index] = updated; + }); + + reply.code(204).send(); +} + export async function clearCartHandler( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply diff --git a/server/src/modules/carts/carts.routes.ts b/server/src/modules/carts/carts.routes.ts index bf8a678..6f8dc40 100644 --- a/server/src/modules/carts/carts.routes.ts +++ b/server/src/modules/carts/carts.routes.ts @@ -4,6 +4,7 @@ import { getCartHandler, addToCartHandler, clearCartHandler, + removeCartProductHandler, type AddToCartBody, } from "@/modules/carts/carts.handlers.js"; import { addToCartSchema } from "@/modules/carts/carts.schemas.js"; @@ -20,4 +21,9 @@ export async function cartRoutes(app: FastifyInstance): Promise { { preHandler: authenticate }, clearCartHandler ); + app.delete<{ Params: { id: string; productId: string } }>( + "/api/carts/:id/products/:productId", + { preHandler: authenticate }, + removeCartProductHandler + ); } diff --git a/specs/001-remove-cart-product/spec.md b/specs/001-remove-cart-product/spec.md new file mode 100644 index 0000000..7788349 --- /dev/null +++ b/specs/001-remove-cart-product/spec.md @@ -0,0 +1,104 @@ +# Remove Product from Cart + +## Meta + +| Field | Value | +| -------------- | -------------------------------- | +| Status | `approved` | +| Author | bartstc | +| Created | 2026-04-25 | +| Last updated | 2026-04-25 | +| Spec directory | `specs/001-remove-cart-product/` | + +--- + +## 1. Goal & Context + +`CartItem` currently shows a static quantity label and a non-functional "Remove" button. A new backend endpoint (`DELETE /api/carts/:id/products/:productId`) now supports single-unit removal. Users need fine-grained quantity management directly in the cart: decrement or increment by one, or remove all units of a product, with a confirmation gate before destructive actions. + +## 2. Requirements + +- **R1**: WHEN the user clicks "-" and product quantity > 1, THE SYSTEM SHALL call the remove endpoint once, apply an optimistic quantity decrement with rollback on failure, show a success toast, and invalidate the cart query on settle. +- **R2**: WHEN the user clicks "-" and product quantity === 1, THE SYSTEM SHALL show a confirmation dialog; upon confirmation, call the remove endpoint once, show a success toast, and invalidate the cart query. +- **R3**: WHEN the user clicks "+", THE SYSTEM SHALL call the existing add-to-cart endpoint for the product and update the cart. +- **R4**: WHEN the user clicks "Remove", THE SYSTEM SHALL show a confirmation dialog; upon confirmation, call the remove endpoint `quantity` times, show a success toast, and invalidate the cart query. +- **R5**: WHEN the confirmation dialog is cancelled or dismissed, THE SYSTEM SHALL close the dialog and make no API calls. +- **R6**: WHEN a remove API call fails (network error or 404), THE SYSTEM SHALL roll back any optimistic update and show an error toast. +- **R7**: WHEN a cart item is rendered, THE SYSTEM SHALL display a "-" icon button, a quantity count, and a "+" icon button as a grouped control alongside the existing "Remove" button. + +## 3. Non-Goals / Out of Scope + +- Storybook stories for new/modified components (separate task) +- Stock or inventory limit enforcement on "+" +- A dedicated batch-removal endpoint — "Remove all" calls the single-unit endpoint N times +- Any changes to the existing add-to-cart flow beyond wiring "+" in `CartItem` + +## 4. Building Blocks Diff + +### Added + +- `removeCartProductMutationOptions` (mutation-options-factory) — DELETE `/api/carts/:id/products/:productId`; `src/lib/api/carts/{cart-id}/remove-cart-product-mutation.ts` +- `useRemoveCartProductMutation` (mutation-hook) — single-unit removal with optimistic quantity decrement + rollback; `src/features/carts/providers/use-remove-cart-product-mutation.ts` +- `useRemoveAllCartProductsMutation` (mutation-hook) — calls remove endpoint N times for a given productId, then invalidates cart query; `src/features/carts/providers/use-remove-all-cart-products-mutation.ts` +- `useRemoveCartProductNotifications` (notification-hook) — maps success/error outcomes to toasts for both single and all-unit removal; `src/features/carts/application/use-remove-cart-product-notifications.ts` +- `useRemoveCartProduct` (use-case-hook) — orchestrates "-" (direct call or open dialog) and "Remove all" (open dialog) flows; `src/features/carts/application/use-remove-cart-product.ts` +- `useConfirmRemoveProductDialogStore` (store) — Zustand store holding `isOpen`, `productId`, `quantity`, and `mode: "decrement" | "remove-all"`; `src/features/carts/components/CartItem/use-confirm-remove-product-dialog-store.ts` +- `ConfirmRemoveProductDialog` (component) — single shared confirmation dialog for last-unit decrement and remove-all; `src/features/carts/components/CartItem/ConfirmRemoveProductDialog.tsx` +- `QuantityControls` (component) — "-" / count / "+" subcomponent with facade-hook encapsulating remove-one and add-to-cart mutation logic; `src/features/carts/components/CartItem/QuantityControls.tsx` + +### Modified + +- `CartItem` (pure-component) — integrate `QuantityControls`, wire "Remove" to `useRemoveCartProduct`, drop `useNotImplementedYetToast`; `src/features/carts/components/CartItem.tsx` +- `CartsList` (pure-component) — render `ConfirmRemoveProductDialog` once at container level; `src/features/carts/components/CartsList.tsx` +- `public/locales/en/translation.json` — add keys under `features.carts.remove-product.*` + +## 5. Design Decisions + +- **Two separate mutation-hooks at providers layer**: `useRemoveCartProductMutation` owns the single-unit call with optimistic update (instant UX for quantity > 1); `useRemoveAllCartProductsMutation` owns the N-call batch with plain invalidation. Clean separation per developer spec. +- **Single shared confirmation dialog**: One `ConfirmRemoveProductDialog` backed by a global Zustand store (carrying `productId`, `quantity`, `mode`) covers both last-unit decrement and remove-all. Rendered once in `CartsList` to avoid N dialog mounts in the list. +- **Optimistic update scope limited to decrement flow**: Only `useRemoveCartProductMutation` applies `onMutate`/`onError` rollback (following `useRateProductMutation` pattern). Confirmed flows don't need optimistic updates — dialog already acknowledges intent. + +## 6. Boundaries + +### ✅ Always + +- Create/modify files under `src/features/carts/` and `src/lib/api/carts/{cart-id}/` +- Add translation keys to `public/locales/en/translation.json` +- Update `src/features/carts/components/CartItem.stories.tsx` only if new required props are added to `CartItem` + +### ⚠️ Ask First + +- Modify the shape of `src/lib/api/carts/{cart-id}/cart-dto.ts` or `cart-product-dto.ts` + +### 🚫 Never + +- Modify `src/features/auth/` or `src/features/authv2/` +- Remove or disable existing tests +- Change the public API of `useAddToCart` (`src/features/carts/application/use-add-to-cart.ts`) + +## 7. Task Breakdown + +1. **[S]** Add `removeCartProductMutationOptions` — `src/lib/api/carts/{cart-id}/remove-cart-product-mutation.ts` (R1, R2, R4, R6) +2. **[S]** Add `useRemoveCartProductMutation` with optimistic decrement + rollback — `src/features/carts/providers/use-remove-cart-product-mutation.ts` (R1, R6) _(depends on 1)_ +3. **[P]** Add `useRemoveAllCartProductsMutation` — `src/features/carts/providers/use-remove-all-cart-products-mutation.ts` (R4, R6) _(depends on 1, parallel with 2)_ +4. **[P]** Add `useRemoveCartProductNotifications` — `src/features/carts/application/use-remove-cart-product-notifications.ts` (R1, R2, R4, R6) +5. **[S]** Add `useRemoveCartProduct` use-case-hook — `src/features/carts/application/use-remove-cart-product.ts` (R1, R2, R4, R5) _(depends on 2, 3, 4)_ +6. **[P]** Add `useConfirmRemoveProductDialogStore` — `src/features/carts/components/CartItem/use-confirm-remove-product-dialog-store.ts` (R2, R4, R5) +7. **[S]** Add `ConfirmRemoveProductDialog` — `src/features/carts/components/CartItem/ConfirmRemoveProductDialog.tsx` (R2, R4, R5) _(depends on 5, 6)_ +8. **[S]** Add `QuantityControls` with facade-hook — `src/features/carts/components/CartItem/QuantityControls.tsx` (R1, R2, R3, R7) _(depends on 5)_ +9. **[S]** Modify `CartItem` — integrate `QuantityControls`, wire "Remove" — `src/features/carts/components/CartItem.tsx` (R4, R7) _(depends on 7, 8)_ +10. **[S]** Modify `CartsList` — render `ConfirmRemoveProductDialog` once — `src/features/carts/components/CartsList.tsx` (R2, R4) _(depends on 7)_ +11. **[P]** Add translation keys — `public/locales/en/translation.json` (R1, R2, R4, R6) + +## 8. Error & Edge Cases + +- GIVEN a remove or add mutation is in-flight for a product, WHEN the user clicks "-" or "+", THEN both buttons are disabled until the request settles (`isPending` prevents double-submit). +- GIVEN optimistic decrement applied (quantity N → N-1), WHEN the API returns 404 or a network error, THEN the cached quantity is restored to N and an error toast is shown (R6). +- GIVEN quantity === 1 and removal is confirmed, WHEN the server responds 200, THEN the product row is absent from the updated cart. +- GIVEN the user confirms "Remove all" with quantity N, WHEN one of the N sequential calls fails mid-sequence, THEN remaining calls are aborted, the cart query is invalidated to fetch authoritative state, and an error toast is shown (R6). +- GIVEN the confirmation dialog is open, WHEN the user clicks the backdrop or presses Escape, THEN the dialog closes and no API call is made (R5). + +## 11. References + +- [use-rate-product-mutation.ts](src/features/marketing/rating/providers/use-rate-product-mutation.ts) — optimistic update pattern to follow +- [ConfirmClearCartDialog.tsx](src/features/carts/components/ClearCartButton/ConfirmClearCartDialog.tsx) — confirmation dialog pattern to follow diff --git a/specs/001-remove-cart-product/tests.md b/specs/001-remove-cart-product/tests.md new file mode 100644 index 0000000..89b05a3 --- /dev/null +++ b/specs/001-remove-cart-product/tests.md @@ -0,0 +1,90 @@ +# Tests: Remove Product from Cart + +Spec: `specs/001-remove-cart-product/spec.md` + +--- + +## 1. New Support Infrastructure + +### MSW Handler — `src/test-lib/handlers/delete-remove-cart-product-handler.ts` + +Factory for `DELETE /carts/:cartId/products/:productId`. Same pattern as `delete-clear-cart-handler.ts`. + +```ts +export const deleteRemoveCartProductHandler = (resolver?: DeleteResolver) => + http.delete(`${host}/carts/:cartId/products/:productId`, (req) => { + if (resolver) return resolver(req); + return HttpResponse.json({}); + }); +``` + +No new fixture needed — existing `CartFixture` and `ProductFixture` are sufficient. + +--- + +## 2. Hook Tests + +### `src/features/carts/application/use-remove-cart-product.test.tsx` + +Block: `hook-test`. Tests `useRemoveCartProduct` (application layer). Covers error paths and the `ProductNotFoundInCartError` distinction that are awkward to reproduce through UI stories. + +Pattern source: `use-add-to-cart.test.tsx` (exact same structure). + +**Setup:** + +- `beforeEach`: `mswServer.use(deleteRemoveCartProductHandler())` +- `afterEach`: `useConfirmRemoveProductDialogStore.setState({ isOpen: false, selectedItem: null })` +- `wrapper`: `TestQueryProvider > TestAuthProvider` + +**Scenarios:** + +| # | Description | MSW override | Expected toast | +| --- | ---------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- | +| 1 | `removeOneFromCart` success | none | `type: "success"`, `"Product quantity decreased."` | +| 2 | `removeOneFromCart` generic server error | `errorResponse(500, "Server error")` | `type: "error"`, `"Something went wrong. Please try again or contact us."` | +| 3 | `removeOneFromCart` product not found | `errorResponse(404, "Product not found in cart")` | `type: "error"`, `"Product not found in your cart. It may have already been removed."` | +| 4 | `removeAllFromCart` success (qty=2) | none (handler responds `{}` twice) | `type: "success"`, `"Product removed from your cart."` | +| 5 | `removeAllFromCart` mid-sequence error | `errorResponse(500, "Server error")` | `type: "error"`, `"Something went wrong. Please try again or contact us."` | + +**Note on scenario 3:** `ProductNotFoundInCartError` is triggered by `e.message === "Product not found in cart"` in `src/lib/api/carts/{cart-id}/remove-cart-product-mutation.ts:21`. Use `errorResponse(404, "Product not found in cart")`. + +--- + +## 3. Component Story Tests + +### `src/features/carts/components/CartItem.stories.tsx` (update) + +Block: `component-story-test`. Verifies user-facing behavior: quantity controls UI, dialog trigger, confirmation flow, and cancel flow. + +**Meta-level changes:** + +- Add `parameters.msw.handlers: [deleteRemoveCartProductHandler()]` +- Add decorator rendering `` alongside `` so dialog portal is in the tree for all stories that trigger it + +**Stories to add:** + +| Story | `quantity` | User action | Assert (via `screen`) | +| -------------------- | ---------- | ---------------------------------------- | ----------------------------------------- | +| `Default` | 4 | — (baseline, no play) | — | +| `DecrementDirectly` | 4 | click `aria-label="Decrease quantity"` | toast `"Product quantity decreased."` | +| `DecrementLastUnit` | 1 | click `aria-label="Decrease quantity"` | dialog title `"Remove last item"` visible | +| `RemoveAllConfirmed` | 4 | click "Remove" → click "Yes, remove all" | toast `"Product removed from your cart."` | +| `RemoveAllCancelled` | 4 | click "Remove" → click "Cancel" | dialog title no longer in DOM | + +**Query rules:** + +- Buttons in the cart item → `canvas.getByRole("button", { name: ... })` or `canvas.getByRole("button", { name: /aria-label/i })` +- Dialog content, toast text → `screen.findBy*` (portaled) +- `await screen.findByRole("alertdialog")` to wait for dialog open; `waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument())` to assert close + +**MSW re-declaration reminder:** Story-level `parameters.msw.handlers` replaces meta-level. None of the stories above override, so all inherit `[deleteRemoveCartProductHandler()]` from meta. + +--- + +## 4. Verification + +```bash +pnpm test # hook tests pass +pnpm typecheck # no type errors +pnpm storybook # play functions pass in Storybook UI +``` diff --git a/specs/lessons.md b/specs/lessons.md index ef63ef8..cc124bf 100644 --- a/specs/lessons.md +++ b/specs/lessons.md @@ -18,26 +18,36 @@ Patterns captured after corrections. Review at session start. --- -## L001 — Translation keys must be added alongside new user-facing values +## L001 — Translation keys ship with user-facing values -**Rule:** When adding any value that renders as a user-facing label (enum value, status, domain constant), always add the corresponding translation key to `public/locales/en-GB/translation.json` in the same task. Remove stale keys when their associated values are removed. +**Rule:** New user-facing value (enum, status, domain constant) → add its key to `public/locales/en-GB/translation.json` in the same task. Remove stale keys when values go away. -**Why it failed:** A new domain value was added without a matching translation key, so the label hook silently fell back to the raw string instead of a human-readable label. +**Why it failed:** A new domain value shipped without a translation key; the label hook silently fell back to the raw string. -**How to apply:** Before finishing any task that introduces a new user-facing value, check the locale file at `public/locales/en-GB/translation.json` and the corresponding label hook or `messageKeys` map in the feature. The translation namespace mirrors the feature path — e.g. `features.products.categories` maps to `translation.json` → `features → products → categories → `. +**How to apply:** Namespace mirrors the feature path — `features.products.categories.` maps to `translation.json` → `features → products → categories → `. Verify against the locale file and the feature's label hook or `messageKeys` map before finishing. **Source:** Correction 2026-04-12 — `Category.Clothing` had no translation key; stale keys were left behind. --- -## L002 — Never use DTO types outside of `providers/` and `models/` +## L002 — Never import DTO types outside `providers/` and `models/` -**Rule:** Types with a `Dto` suffix (API models) live in `src/lib/api/`. They must never be imported directly into `components/`, `application/`, or `pages/`. Instead, use Domain (frontend) models or if Domain model resembles DTO re-export the DTO under a domain name from `features//models/` (e.g. `export type { MarketingProductDto as MarketingProduct }`) and import that model type everywhere else. +**Rule:** `Dto`-suffixed types (from `src/lib/api/`) must not be imported by `components/`, `application/`, or `pages/`. Use a domain model from `features//models/`, or re-export with `export type { XxxDto as Xxx } from "@/lib/api/..."` when the domain shape matches the DTO. -**Why it failed:** When lifting a fetch from a component to a page, `MarketingProductDto` was imported directly into `ProductDetails.tsx` instead of going through `models/`. `docs/architecture.md` documents this boundary: `providers/` is the data access gateway and DTO types must not leak beyond it. +**Why it failed:** Lifting a fetch from a component to a page, `MarketingProductDto` was imported directly into `ProductDetails.tsx`. Per `docs/architecture.md`, `providers/` is the data-access gateway — DTOs must not leak past it. -**How to apply:** Before using any type from `src/lib/api/` in a component or page, check whether a domain alias exists in the feature's `models/`. If not, create one following the pattern in `product.ts`: `export type { XxxDto as Xxx } from "@/lib/api/..."`. +**How to apply:** Before using any `src/lib/api/` type in a component or page, check `features//models/` for a domain alias. If none exists, add one following `product.ts`. **Source:** Correction 2026-04-12 — `MarketingProductDto` leaked into `ProductDetails.tsx` component props. --- + +## L003 — Invoke skills via the Skill tool before exploring code + +**Rule:** Skill name in the prompt → call `Skill({ skill: "" })` FIRST. No Read, Grep, or Explore subagent before the skill has loaded. + +**Why it failed:** When `test-building-blocks` was referenced, Explore agents scanned the codebase instead — producing reverse-engineered patterns the rule files already capture, and bleeding code examples into `specs/001-remove-cart-product/tests.md` instead of building-block names. + +**How to apply:** First action on any prompt mentioning a skill. After the skill loads, read only the `rules/.md` files for the blocks you'll implement. Existing source files are read afterward and only for integration points (imports, symbols, fixture names, handler paths) — never to learn patterns. + +**Source:** Correction 2026-04-25 — `test-building-blocks` not invoked when planning tests for the remove-cart-product feature. diff --git a/src/features/carts/application/use-add-to-cart.test.tsx b/src/features/carts/application/use-add-to-cart.test.tsx index f5038e6..59d820b 100644 --- a/src/features/carts/application/use-add-to-cart.test.tsx +++ b/src/features/carts/application/use-add-to-cart.test.tsx @@ -1,9 +1,8 @@ import { act, renderHook } from "@testing-library/react"; import type { PropsWithChildren } from "react"; -import { afterEach, beforeEach, expect, it } from "vitest"; +import { beforeEach, expect, it } from "vitest"; import { initializeAuthStore } from "@/features/auth/application/auth-store"; -import { USER_CART_ID } from "@/test-lib/fixtures/user-fixture"; import { generateUuid } from "@/test-lib/generate-uuid"; import { errorResponse } from "@/test-lib/handlers/error-responses"; import { putAddToCartHandler } from "@/test-lib/handlers/put-add-to-cart-handler"; @@ -13,14 +12,10 @@ import { TestQueryProvider } from "@/test-lib/TestQueryProvider"; import { spyOnToast } from "@/test-lib/toast-spy"; import { useAddToCart } from "./use-add-to-cart"; -import { useProductAddedDialogStore } from "./use-product-added-dialog-store"; beforeEach(() => { mswServer.use(putAddToCartHandler()); }); -afterEach(() => { - useProductAddedDialogStore.setState({ isOpen: false, selectedItem: null }); -}); const wrapper = ({ children }: PropsWithChildren) => ( @@ -49,8 +44,6 @@ it("opens the product added dialog and shows a success toast after adding to car await result.current.addToCart(generateUuid()); }); - expect(useProductAddedDialogStore.getState().isOpen).toBe(true); - expect(useProductAddedDialogStore.getState().selectedItem).toBe(USER_CART_ID); expect(toastSpy).toHaveBeenCalledWith( expect.objectContaining({ type: "success", @@ -69,7 +62,6 @@ it("shows a warning toast and keeps the dialog closed when unauthenticated", asy await result.current.addToCart(generateUuid()); }); - expect(useProductAddedDialogStore.getState().isOpen).toBe(false); expect(toastSpy).toHaveBeenCalledWith( expect.objectContaining({ type: "warning", @@ -89,7 +81,6 @@ it("shows an error toast and keeps the dialog closed when the server returns Unk await result.current.addToCart(generateUuid()); }); - expect(useProductAddedDialogStore.getState().isOpen).toBe(false); expect(toastSpy).toHaveBeenCalledWith( expect.objectContaining({ type: "error", diff --git a/src/features/carts/application/use-add-to-cart.ts b/src/features/carts/application/use-add-to-cart.ts index 3a2efa2..5422c50 100644 --- a/src/features/carts/application/use-add-to-cart.ts +++ b/src/features/carts/application/use-add-to-cart.ts @@ -1,5 +1,4 @@ import { useAuthStore } from "@/features/auth/application/auth-store"; -import { useProductAddedDialogStore } from "@/features/carts/application/use-product-added-dialog-store"; import { useAddToCartMutation, UnknownProductError, @@ -19,31 +18,31 @@ export const useAddToCart = () => { notifyUnknownProduct, notifyProductNotAvailable, } = useAddToCartNotifications(); - const onOpen = useProductAddedDialogStore((store) => store.onOpen); - const addToCart = async (productId: string) => { + const addToCart = async (productId: string): Promise => { if (!isAuthenticated) { notifyNotAuthenticated(); - return; + return false; } try { await mutateAsync(cartId, { productId, quantity: 1 }); notifySuccess(); - onOpen(cartId); + return true; } catch (e) { if (e instanceof UnknownProductError) { notifyUnknownProduct(); - return; + return false; } // todo: not implemented yet on the backend if (e instanceof ProductNotAvailableError) { notifyProductNotAvailable(); - return; + return false; } notifyFailure(); + return false; } }; diff --git a/src/features/carts/application/use-remove-cart-product-notifications.ts b/src/features/carts/application/use-remove-cart-product-notifications.ts new file mode 100644 index 0000000..6da2278 --- /dev/null +++ b/src/features/carts/application/use-remove-cart-product-notifications.ts @@ -0,0 +1,42 @@ +import { useToast } from "@/lib/components/Toast/use-toast"; +import { useTranslations } from "@/lib/i18n/use-transations"; + +export const useRemoveCartProductNotifications = () => { + const t = useTranslations("features.carts.remove-product.notifications"); + const toast = useToast(); + + const notifyDecrementSuccess = () => + toast({ + status: "success", + title: t("title"), + description: t("decrement-success"), + }); + + const notifyRemoveAllSuccess = () => + toast({ + status: "success", + title: t("title"), + description: t("remove-all-success"), + }); + + const notifyProductNotFoundError = () => + toast({ + status: "error", + title: t("title"), + description: t("product-not-found-error"), + }); + + const notifyFailure = () => + toast({ + status: "error", + title: t("title"), + description: t("error"), + }); + + return { + notifyDecrementSuccess, + notifyRemoveAllSuccess, + notifyProductNotFoundError, + notifyFailure, + }; +}; diff --git a/src/features/carts/application/use-remove-cart-product.test.tsx b/src/features/carts/application/use-remove-cart-product.test.tsx new file mode 100644 index 0000000..9fdf86d --- /dev/null +++ b/src/features/carts/application/use-remove-cart-product.test.tsx @@ -0,0 +1,131 @@ +import { act, renderHook } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { initializeAuthStore } from "@/features/auth/application/auth-store"; +import { generateUuid } from "@/test-lib/generate-uuid"; +import { deleteRemoveCartProductHandler } from "@/test-lib/handlers/delete-remove-cart-product-handler"; +import { errorResponse } from "@/test-lib/handlers/error-responses"; +import { mswServer } from "@/test-lib/msw-server"; +import { TestAuthProvider } from "@/test-lib/TestAuthProvider"; +import { TestQueryProvider } from "@/test-lib/TestQueryProvider"; +import { spyOnToast } from "@/test-lib/toast-spy"; + +import { useRemoveCartProduct } from "./use-remove-cart-product"; + +beforeEach(() => { + mswServer.use(deleteRemoveCartProductHandler()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +); + +const unauthenticatedWrapper = ({ children }: PropsWithChildren) => { + const store = initializeAuthStore({ + isAuthenticated: false, + isError: false, + state: "finished", + }); + return ( + + {children} + + ); +}; + +it("shows a success toast after removing one unit from the cart", async () => { + const toastSpy = spyOnToast(); + const { result } = renderHook(() => useRemoveCartProduct(), { wrapper }); + + await act(async () => { + await result.current.removeOneFromCart(generateUuid()); + }); + + expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "success", + description: "Product removed.", + }) + ); +}); + +it("shows a success toast after removing all units of a product", async () => { + const toastSpy = spyOnToast(); + const { result } = renderHook(() => useRemoveCartProduct(), { wrapper }); + + await act(async () => { + await result.current.removeAllFromCart(generateUuid(), 3); + }); + + expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "success", + description: "Product removed from your cart.", + }) + ); +}); + +it("shows a product-not-found error toast when the server returns 'Product not found in cart'", async () => { + mswServer.use( + deleteRemoveCartProductHandler(() => + errorResponse(404, "Product not found in cart") + ) + ); + const toastSpy = spyOnToast(); + const { result } = renderHook(() => useRemoveCartProduct(), { wrapper }); + + await act(async () => { + await result.current.removeOneFromCart(generateUuid()); + }); + + expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + description: + "Product not found in your cart. It may have already been removed.", + }) + ); +}); + +it("shows a generic error toast when an unexpected server error occurs", async () => { + mswServer.use( + deleteRemoveCartProductHandler(() => + errorResponse(500, "Internal server error") + ) + ); + const toastSpy = spyOnToast(); + const { result } = renderHook(() => useRemoveCartProduct(), { wrapper }); + + await act(async () => { + await result.current.removeOneFromCart(generateUuid()); + }); + + expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + description: "Something went wrong. Please try again or contact us.", + }) + ); +}); + +it("returns false and shows no toast when the user is not authenticated", async () => { + const toastSpy = spyOnToast(); + const { result } = renderHook(() => useRemoveCartProduct(), { + wrapper: unauthenticatedWrapper, + }); + + let returnValue: boolean | undefined; + await act(async () => { + returnValue = await result.current.removeOneFromCart(generateUuid()); + }); + + expect(returnValue).toBe(false); + expect(toastSpy).not.toHaveBeenCalled(); +}); diff --git a/src/features/carts/application/use-remove-cart-product.ts b/src/features/carts/application/use-remove-cart-product.ts new file mode 100644 index 0000000..7ae4660 --- /dev/null +++ b/src/features/carts/application/use-remove-cart-product.ts @@ -0,0 +1,61 @@ +import { useAuthStore } from "@/features/auth/application/auth-store"; +import { useRemoveAllCartProductsMutation } from "@/features/carts/providers/use-remove-all-cart-products-mutation"; +import { + ProductNotFoundInCartError, + useRemoveCartProductMutation, +} from "@/features/carts/providers/use-remove-cart-product-mutation"; + +import { useRemoveCartProductNotifications } from "./use-remove-cart-product-notifications"; + +export const useRemoveCartProduct = () => { + const cartId = useAuthStore((store) => store.user?.cartId); + const [removeSingle, isRemoveSinglePending] = useRemoveCartProductMutation(); + const [removeAll, isRemoveAllPending] = useRemoveAllCartProductsMutation(); + const { + notifyDecrementSuccess, + notifyRemoveAllSuccess, + notifyProductNotFoundError, + notifyFailure, + } = useRemoveCartProductNotifications(); + + const removeOneFromCart = async (productId: string): Promise => { + if (!cartId) return false; + try { + await removeSingle(cartId, productId); + notifyDecrementSuccess(); + return true; + } catch (e) { + if (e instanceof ProductNotFoundInCartError) { + notifyProductNotFoundError(); + } else { + notifyFailure(); + } + return false; + } + }; + + const removeAllFromCart = async ( + productId: string, + quantity: number + ): Promise => { + if (!cartId) return false; + try { + await removeAll(cartId, productId, quantity); + notifyRemoveAllSuccess(); + return true; + } catch (e) { + if (e instanceof ProductNotFoundInCartError) { + notifyProductNotFoundError(); + } else { + notifyFailure(); + } + return false; + } + }; + + return { + removeOneFromCart, + removeAllFromCart, + isPending: isRemoveSinglePending || isRemoveAllPending, + }; +}; diff --git a/src/features/carts/components/AddToCartButton/AddToCartButton.tsx b/src/features/carts/components/AddToCartButton/AddToCartButton.tsx index 49971a3..52b65d5 100644 --- a/src/features/carts/components/AddToCartButton/AddToCartButton.tsx +++ b/src/features/carts/components/AddToCartButton/AddToCartButton.tsx @@ -1,6 +1,8 @@ import { Button, type ButtonProps } from "@chakra-ui/react"; +import { useAuthStore } from "@/features/auth/application/auth-store"; import { useAddToCart } from "@/features/carts/application/use-add-to-cart"; +import { useProductAddedDialogStore } from "@/features/carts/application/use-product-added-dialog-store"; import { useTranslations } from "@/lib/i18n/use-transations"; interface IProps { @@ -10,14 +12,16 @@ interface IProps { const AddToCartButton = ({ productId, colorPalette = "gray" }: IProps) => { const t = useTranslations("features.carts.add-to-cart"); - const { addToCart, isPending } = useAddToCart(); + const { onAddToCart, isPending } = useAddToCartButton(productId); return ( @@ -25,3 +29,16 @@ const AddToCartButton = ({ productId, colorPalette = "gray" }: IProps) => { }; export { AddToCartButton }; + +const useAddToCartButton = (productId: string) => { + const { addToCart, isPending } = useAddToCart(); + const cartId = useAuthStore((store) => store.user?.cartId); + const onOpen = useProductAddedDialogStore((store) => store.onOpen); + + const onAddToCart = async () => { + const success = await addToCart(productId); + if (success) onOpen(cartId); + }; + + return { onAddToCart, isPending }; +}; diff --git a/src/features/carts/components/CartItem.stories.tsx b/src/features/carts/components/CartItem.stories.tsx index 1a6b856..6a2bfe7 100644 --- a/src/features/carts/components/CartItem.stories.tsx +++ b/src/features/carts/components/CartItem.stories.tsx @@ -1,17 +1,36 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen } from "storybook/test"; import { withRouter } from "storybook-addon-remix-react-router"; import { ProductFixture } from "@/test-lib/fixtures/product-fixture"; +import { deleteRemoveCartProductHandler } from "@/test-lib/handlers/delete-remove-cart-product-handler"; +import { putAddToCartHandler } from "@/test-lib/handlers/put-add-to-cart-handler"; import { CartItem } from "./CartItem"; +import { ConfirmRemoveProductDialog } from "./CartItem/ConfirmRemoveProductDialog"; +import { useConfirmRemoveProductDialogStore } from "./CartItem/use-confirm-remove-product-dialog-store"; const meta = { title: "modules/Carts/CartItem", component: CartItem, - decorators: [withRouter], + decorators: [ + (story) => ( + <> + {story()} + + + ), + withRouter, + ], parameters: { layout: "centered", }, + beforeEach: () => { + useConfirmRemoveProductDialogStore.setState({ + isOpen: false, + selectedItem: null, + }); + }, } satisfies Meta; const product = ProductFixture.toStructure(); @@ -29,3 +48,76 @@ export const Default: Story = { quantity: 4, }, }; + +export const DecrementAboveOne: Story = { + args: { + ...Default.args, + quantity: 4, + }, + parameters: { + msw: { + handlers: [deleteRemoveCartProductHandler(), putAddToCartHandler()], + }, + }, + play: async ({ step }) => { + await step("Click decrease quantity button", () => { + screen.getByRole("button", { name: "Decrease quantity" }).click(); + }); + + await step("Toast confirms quantity decreased", async () => { + await expect( + await screen.findByText("Product removed.") + ).toBeInTheDocument(); + }); + }, +}; + +export const DecrementLastUnit: Story = { + args: { + ...Default.args, + quantity: 1, + }, + parameters: { + msw: { + handlers: [deleteRemoveCartProductHandler(), putAddToCartHandler()], + }, + }, + play: async ({ step }) => { + await step("Click decrease quantity button", () => { + screen.getByRole("button", { name: "Decrease quantity" }).click(); + }); + + await step("Decrement confirmation dialog appears", async () => { + await expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); + await expect(screen.getByText("Remove last item")).toBeInTheDocument(); + await expect( + screen.getByRole("button", { name: "Yes, remove" }) + ).toBeInTheDocument(); + }); + }, +}; + +export const RemoveAllOpensDialog: Story = { + args: { + ...Default.args, + quantity: 4, + }, + parameters: { + msw: { + handlers: [deleteRemoveCartProductHandler(), putAddToCartHandler()], + }, + }, + play: async ({ step }) => { + await step("Click Remove button", () => { + screen.getByRole("button", { name: "Remove" }).click(); + }); + + await step("Remove-all confirmation dialog appears", async () => { + await expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); + await expect(screen.getByText("Remove product")).toBeInTheDocument(); + await expect( + screen.getByRole("button", { name: "Yes, remove all" }) + ).toBeInTheDocument(); + }); + }, +}; diff --git a/src/features/carts/components/CartItem.tsx b/src/features/carts/components/CartItem.tsx index 9ce479b..a5ef938 100644 --- a/src/features/carts/components/CartItem.tsx +++ b/src/features/carts/components/CartItem.tsx @@ -12,13 +12,15 @@ import { Check } from "lucide-react"; import { useCategoryLabel } from "@/features/products/components/use-category-label"; import { type Category } from "@/features/products/models/category"; -import { useNotImplementedYetToast } from "@/lib/components/Toast/use-not-implemented-yet-toast"; import { moneyVO } from "@/lib/format/money"; import { useTranslations } from "@/lib/i18n/use-transations"; import { useNavigate } from "@/lib/router"; import { routes } from "@/lib/router/routes"; import { useSecondaryTextColor } from "@/lib/theme/use-secondary-text-color"; +import { QuantityControls } from "./CartItem/QuantityControls"; +import { useConfirmRemoveProductDialogStore } from "./CartItem/use-confirm-remove-product-dialog-store"; + interface IProps { id: string; name: string; @@ -40,7 +42,7 @@ const CartItem = ({ const t = useTranslations("features.carts.item"); const categoryLabel = useCategoryLabel(category); const categoryColor = useSecondaryTextColor(); - const notImplemented = useNotImplementedYetToast(); + const openRemoveDialog = useConfirmRemoveProductDialogStore((s) => s.onOpen); return ( {categoryLabel} - - {t("quantity")} {quantity} - + @@ -109,7 +109,13 @@ const CartItem = ({ {moneyVO.format(price.amount, price.currency)} - diff --git a/src/features/carts/components/CartItem/ConfirmRemoveProductDialog.tsx b/src/features/carts/components/CartItem/ConfirmRemoveProductDialog.tsx new file mode 100644 index 0000000..d383154 --- /dev/null +++ b/src/features/carts/components/CartItem/ConfirmRemoveProductDialog.tsx @@ -0,0 +1,87 @@ +import { + Button, + CloseButton, + Dialog, + Portal, + VStack, + Text, +} from "@chakra-ui/react"; + +import { useRemoveCartProduct } from "@/features/carts/application/use-remove-cart-product"; +import { useTranslations } from "@/lib/i18n/use-transations"; + +import { useConfirmRemoveProductDialogStore } from "./use-confirm-remove-product-dialog-store"; + +const ConfirmRemoveProductDialog = () => { + const { removeOneFromCart, removeAllFromCart, isPending } = + useRemoveCartProduct(); + const t = useTranslations("features.carts.remove-product.dialog"); + + const { isOpen, selectedItem, onClose } = useConfirmRemoveProductDialogStore( + (state) => ({ + isOpen: state.isOpen, + selectedItem: state.selectedItem, + onClose: state.onClose, + }) + ); + + const isDecrement = selectedItem?.mode === "decrement"; + + const handleConfirm = () => { + if (!selectedItem) return; + const action = isDecrement + ? removeOneFromCart(selectedItem.productId) + : removeAllFromCart(selectedItem.productId, selectedItem.quantity); + void action.then((success) => { + if (success) onClose(); + }); + }; + + return ( + { + if (!e.open) onClose(); + }} + > + + + + + + + {isDecrement ? t("decrement.title") : t("remove-all.title")} + + + + + + + + + {isDecrement + ? t("decrement.message") + : t("remove-all.message")} + + + + + + + + + + + + ); +}; + +export { ConfirmRemoveProductDialog }; diff --git a/src/features/carts/components/CartItem/QuantityControls.tsx b/src/features/carts/components/CartItem/QuantityControls.tsx new file mode 100644 index 0000000..be8f07b --- /dev/null +++ b/src/features/carts/components/CartItem/QuantityControls.tsx @@ -0,0 +1,78 @@ +import { HStack, IconButton, Text } from "@chakra-ui/react"; +import { Minus, Plus } from "lucide-react"; + +import { useAddToCart } from "@/features/carts/application/use-add-to-cart"; +import { useRemoveCartProduct } from "@/features/carts/application/use-remove-cart-product"; +import { useTranslations } from "@/lib/i18n/use-transations"; + +import { useConfirmRemoveProductDialogStore } from "./use-confirm-remove-product-dialog-store"; + +interface Props { + productId: string; + quantity: number; +} + +const QuantityControls = ({ productId, quantity }: Props) => { + const { onDecrement, onIncrement, isDisabled } = useQuantityControls( + productId, + quantity + ); + const t = useTranslations("features.carts.remove-product.quantity-controls"); + + return ( + + { + void onDecrement(); + }} + disabled={isDisabled} + > + + + + {quantity} + + { + void onIncrement(); + }} + disabled={isDisabled} + > + + + + ); +}; + +export { QuantityControls }; + +const useQuantityControls = (productId: string, quantity: number) => { + const { removeOneFromCart, isPending: isRemovePending } = + useRemoveCartProduct(); + const { addToCart, isPending: isAddPending } = useAddToCart(); + const openDialog = useConfirmRemoveProductDialogStore((s) => s.onOpen); + + const onDecrement = async () => { + if (quantity > 1) { + await removeOneFromCart(productId); + } else { + openDialog({ productId, quantity, mode: "decrement" }); + } + }; + + const onIncrement = async () => { + await addToCart(productId); + }; + + return { + onDecrement, + onIncrement, + isDisabled: isRemovePending || isAddPending, + }; +}; diff --git a/src/features/carts/components/CartItem/use-confirm-remove-product-dialog-store.ts b/src/features/carts/components/CartItem/use-confirm-remove-product-dialog-store.ts new file mode 100644 index 0000000..5a27715 --- /dev/null +++ b/src/features/carts/components/CartItem/use-confirm-remove-product-dialog-store.ts @@ -0,0 +1,10 @@ +import { createModalStore } from "@/lib/components/Modal/create-modal-store"; + +interface RemoveProductDialogItem { + productId: string; + quantity: number; + mode: "decrement" | "remove-all"; +} + +export const useConfirmRemoveProductDialogStore = + createModalStore(); diff --git a/src/features/carts/components/CartsList.tsx b/src/features/carts/components/CartsList.tsx index dcdef21..70bc587 100644 --- a/src/features/carts/components/CartsList.tsx +++ b/src/features/carts/components/CartsList.tsx @@ -3,6 +3,7 @@ import { ArrowRight } from "lucide-react"; import { type ComponentProps, Fragment } from "react"; import { CartItem } from "@/features/carts/components/CartItem"; +import { ConfirmRemoveProductDialog } from "@/features/carts/components/CartItem/ConfirmRemoveProductDialog"; import { CheckoutButton } from "@/features/carts/components/CheckoutButton/CheckoutButton"; import { moneyVO } from "@/lib/format/money"; import { useTranslations } from "@/lib/i18n/use-transations"; @@ -60,6 +61,7 @@ const CartsList = ({ cartProducts }: IProps) => { + ); }; diff --git a/src/features/carts/providers/use-remove-all-cart-products-mutation.ts b/src/features/carts/providers/use-remove-all-cart-products-mutation.ts new file mode 100644 index 0000000..0e8151c --- /dev/null +++ b/src/features/carts/providers/use-remove-all-cart-products-mutation.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; +import { removeCartProductMutationOptions } from "@/lib/api/carts/{cart-id}/remove-cart-product-mutation"; + +export const useRemoveAllCartProductsMutation = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isPending } = useMutation( + removeCartProductMutationOptions + ); + + const handler = async ( + cartId: string, + productId: string, + quantity: number + ): Promise => { + try { + for (let i = 0; i < quantity; i++) { + await mutateAsync({ cartId, productId }); + } + } finally { + await queryClient.invalidateQueries({ queryKey: cartQueryKeys.all }); + } + }; + + return [handler, isPending] as const; +}; diff --git a/src/features/carts/providers/use-remove-cart-product-mutation.ts b/src/features/carts/providers/use-remove-cart-product-mutation.ts new file mode 100644 index 0000000..4bf153c --- /dev/null +++ b/src/features/carts/providers/use-remove-cart-product-mutation.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { cartQueryKeys } from "@/lib/api/carts/cart-query-keys"; +import type { CartProductDto } from "@/lib/api/carts/{cart-id}/cart-product-dto"; +import { + removeCartProductMutationOptions, + type RemoveCartProductDto, +} from "@/lib/api/carts/{cart-id}/remove-cart-product-mutation"; + +interface CartProductsCache { + date: string; + products: CartProductDto[]; +} + +interface MutationContext { + previous: CartProductsCache | undefined; + cartId: string; +} + +export { ProductNotFoundInCartError } from "@/lib/api/carts/{cart-id}/remove-cart-product-mutation"; + +export const useRemoveCartProductMutation = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isPending } = useMutation< + void, + Error, + RemoveCartProductDto, + MutationContext + >({ + ...removeCartProductMutationOptions, + onMutate: async ({ cartId, productId }) => { + const queryKey = cartQueryKeys.products(cartId); + await queryClient.cancelQueries({ queryKey }); + + const previous = queryClient.getQueryData(queryKey); + + if (previous) { + queryClient.setQueryData(queryKey, { + ...previous, + products: previous.products.map((p) => + p.id === productId ? { ...p, quantity: p.quantity - 1 } : p + ), + }); + } + + return { previous, cartId }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData( + cartQueryKeys.products(context.cartId), + context.previous + ); + } + }, + onSettled: (_data, _err, { cartId }) => { + void queryClient.invalidateQueries({ + queryKey: cartQueryKeys.products(cartId), + }); + }, + }); + + const handler = (cartId: string, productId: string) => + mutateAsync({ cartId, productId }); + + return [handler, isPending] as const; +}; diff --git a/src/lib/api/carts/{cart-id}/remove-cart-product-mutation.ts b/src/lib/api/carts/{cart-id}/remove-cart-product-mutation.ts new file mode 100644 index 0000000..03cb38e --- /dev/null +++ b/src/lib/api/carts/{cart-id}/remove-cart-product-mutation.ts @@ -0,0 +1,34 @@ +import { mutationOptions } from "@tanstack/react-query"; + +import { httpService } from "@/lib/http"; +import { Logger } from "@/lib/logger"; +import { UnknownError } from "@/lib/types/unknown-error"; + +export interface RemoveCartProductDto { + cartId: string; + productId: string; +} + +export const removeCartProductMutationOptions = mutationOptions({ + mutationFn: async ({ + cartId, + productId, + }: RemoveCartProductDto): Promise => { + try { + await httpService.delete(`carts/${cartId}/products/${productId}`); + } catch (e) { + Logger.error("Failed to remove product from cart", e as Error); + if (httpService.isError(e) && e.message === "Product not found in cart") { + throw new ProductNotFoundInCartError(); + } + throw new UnknownError(); + } + }, +}); + +export class ProductNotFoundInCartError extends Error { + constructor() { + super("Product not found in cart"); + this.name = "ProductNotFoundInCartError"; + } +} diff --git a/src/lib/components/Form/presentation/SelectInput.tsx b/src/lib/components/Form/presentation/SelectInput.tsx index 0f5125d..2420f6b 100644 --- a/src/lib/components/Form/presentation/SelectInput.tsx +++ b/src/lib/components/Form/presentation/SelectInput.tsx @@ -5,7 +5,8 @@ import { useFieldContext, } from "@chakra-ui/react"; import { useMemo, type ReactNode, type Ref } from "react"; -import { useTranslation } from "react-i18next"; + +import { useTranslations } from "@/lib/i18n/use-transations"; export interface OptionType { label: string; @@ -33,7 +34,7 @@ export interface SelectProps { function SelectInput( props: SelectProps ) { - const { t } = useTranslation(); + const t = useTranslations("shared.form.select"); const fieldContext = useFieldContext(); const { options, @@ -89,8 +90,8 @@ function SelectInput( }; const emptyContent = isLoading - ? (loadingMessage?.() ?? t("shared.form.select.loading", "Loading…")) - : (noOptionsMessage?.() ?? t("shared.form.select.noResults", "No results")); + ? (loadingMessage?.() ?? t("loading", "Loading…")) + : (noOptionsMessage?.() ?? t("noResults", "No results")); return ( { - // eslint-disable-next-line no-useless-escape - const regex = /\/(?[^\/]+)\/(?[^\/]+)\.json$/; + const regex = /\/locales\/(?[^/]+)\/(?[^/]+)\.json$/; if (!regex.test(file)) return; const { lng, namespace } = regex.exec(file)?.groups ?? {}; diff --git a/src/lib/i18n/use-transations.ts b/src/lib/i18n/use-transations.ts index 0ab0b26..b74009f 100644 --- a/src/lib/i18n/use-transations.ts +++ b/src/lib/i18n/use-transations.ts @@ -1,12 +1,27 @@ -import type { FlatNamespace, KeyPrefix } from "i18next"; -import type { FallbackNs } from "react-i18next"; import { useTranslation } from "react-i18next"; -type $Tuple = readonly [T?, ...T[]]; +// AIDEV-NOTE: Maps a dotted prefix to an i18next namespace + keyPrefix so call sites +// can keep the "features.carts.item" / "pages.products" / "shared.x" form while +// resources live in flat per-area JSON files (carts.json, pages.json, shared.json...). +// The "features." prefix is stripped because nested directories under public/ caused +// HTTP backend loads to fail (only top-level locale files were resolved reliably). +function resolveNamespace(path: string) { + const segments = path.split("."); -export function useTranslations< - Ns extends FlatNamespace | $Tuple | undefined = undefined, - KPrefix extends KeyPrefix> = undefined, ->(namespace?: KPrefix) { - return useTranslation(undefined, { keyPrefix: namespace }).t; + if (segments[0] === "features" && segments[1]) { + return { + ns: segments[1], + keyPrefix: segments.slice(2).join(".") || undefined, + }; + } + + return { + ns: segments[0], + keyPrefix: segments.slice(1).join(".") || undefined, + }; +} + +export function useTranslations(namespace: string) { + const { ns, keyPrefix } = resolveNamespace(namespace); + return useTranslation(ns, { keyPrefix }).t; } diff --git a/src/test-lib/handlers/delete-remove-cart-product-handler.ts b/src/test-lib/handlers/delete-remove-cart-product-handler.ts new file mode 100644 index 0000000..e9dff12 --- /dev/null +++ b/src/test-lib/handlers/delete-remove-cart-product-handler.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse } from "msw"; + +import { host } from "@/lib/http"; + +import type { DeleteResolver } from "./resolvers"; + +export const deleteRemoveCartProductHandler = (resolver?: DeleteResolver) => + http.delete(`${host}/carts/:cartId/products/:productId`, (req) => { + if (resolver) return resolver(req); + + return HttpResponse.json({}); + }); diff --git a/src/test-lib/init-i18n.ts b/src/test-lib/init-i18n.ts index 2ef9e89..bb1cd7e 100644 --- a/src/test-lib/init-i18n.ts +++ b/src/test-lib/init-i18n.ts @@ -1,7 +1,12 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import * as enGB from "../../public/locales/en-GB/translation.json"; +import auth from "../../public/locales/en-GB/auth.json"; +import carts from "../../public/locales/en-GB/carts.json"; +import marketing from "../../public/locales/en-GB/marketing.json"; +import pages from "../../public/locales/en-GB/pages.json"; +import products from "../../public/locales/en-GB/products.json"; +import shared from "../../public/locales/en-GB/shared.json"; export const i18nInstance = i18n; @@ -12,9 +17,16 @@ export async function initializeI18n() { lng: "en-GB", fallbackLng: "en-GB", debug: false, + ns: ["pages", "shared", "marketing", "products", "carts", "auth"], + defaultNS: "shared", resources: { "en-GB": { - translation: enGB, + pages, + shared, + marketing, + products, + carts, + auth, }, }, interpolation: {