From 214423426430da21d78f3e33c39737dec6ce3fd2 Mon Sep 17 00:00:00 2001 From: Arnaud Zheng Date: Tue, 1 Sep 2026 23:45:41 +0200 Subject: [PATCH] refactor(domain): tighten the object calisthenics story - CartItems: a first-class collection absorbing the find/map/filter/reduce that used to be duplicated three times in Cart, replacing getItemsCopy() - Cart now takes item ids (increaseQuantity, decreaseQuantity, removeItem), so the UI no longer pulls a Product back out of a CartItem just to hand it back to Cart. Added remainingForFreeShipping(), the Money arithmetic that used to live in JSX - dropped CartItem.getProduct() (the source of that Demeter chain) and the dead getPrice(); renamed the rest to id()/name()/image()/quantity() to match the rest of the domain's naming - quantity clamping moved off CartItem onto Quantity (clampBetween, isAtLeast, isAtMost), Money got isAtLeast/isZero so policies stop comparing raw .toAmount() values - Skill's SkillValue and Product's KnowledgeProfile were the same concept with two vocabularies (intermediate vs adventurer) bridged by a ProductCardLogic shim. Unified on one type, deleted the shim - GoldSilverCopperFormatter split into GoldSilverCopperAmount (conversion) and GoldSilverCopperFormatter (presentation), both got the specs they never had - Stats.asObject() dissolved the value object into a plain record the UI had to re-derive labels from (and rendered "ManaBoost: 0" rows). Stats.list() now returns labeled, non-zero entries directly - a product's lore link and video never actually varied by knowledge level, only the data entry did (same two URLs repeated three times per product). Moved ExtraResources onto Product itself - data/products.ts is now a ProductCatalog built from named blueprints instead of a bare array of five-deep positional constructor calls - moved the three WelcomeSurvey storage repositories out of a UI component folder into domain/welcomeSurvey/infrastructure/ - shared test fixtures (productFixtures.ts) replace the ~90 lines of product construction duplicated across Cart.spec.ts and CartItem.spec.ts Cart went from 90 lines to 58, CartItem from 75 to 51: close to the 50-line rule, not exactly on it, and a first-class collection exists now where none did before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014BcGPRc3sgUJYKihGRXh5g --- src/config/analytics.config.ts | 6 + src/config/storage.config.ts | 2 +- src/data/products.ts | 414 +++++++++--------- src/domain/cart/Cart.ts | 80 ++-- src/domain/cart/CartItem.ts | 86 ++-- src/domain/cart/CartItems.ts | 75 ++++ src/domain/cart/__spec__/Cart.spec.ts | 172 +++----- src/domain/cart/__spec__/CartItem.spec.ts | 96 +--- src/domain/cart/__spec__/productFixtures.ts | 36 ++ src/domain/cart/policy/ShippingPolicy.ts | 2 +- src/domain/cart/value-objects/Money.ts | 10 +- .../cart/value-objects/Product/Product.ts | 69 +-- .../value-objects/Product/ProductDetails.ts | 51 +-- .../Product/__spec__/Product.spec.ts | 52 ++- .../Product/__spec__/ProductDetails.spec.ts | 56 +-- src/domain/cart/value-objects/Quantity.ts | 16 +- .../cart/value-objects/__spec__/Money.spec.ts | 11 + .../value-objects/__spec__/Quantity.spec.ts | 26 ++ src/domain/currency/GoldSilverCopperAmount.ts | 50 +++ .../currency/GoldSilverCopperFormatter.ts | 35 +- .../__spec__/GoldSilverCopperAmount.spec.ts | 44 ++ .../GoldSilverCopperFormatter.spec.ts | 22 + .../LocalStorageWelcomeSurvey.repository.ts | 0 .../TanstackStoreWelcomeSurvey.repository.ts | 0 .../ZustandWelcomeSurvey.repository.ts | 0 ...calStorageWelcomeSurvey.repository.spec.ts | 0 ...stackStoreWelcomeSurvey.repository.spec.ts | 0 .../cart/ProductCard/ProductCard.logic.ts | 15 - .../cart/ProductCard/ProductCardContent.tsx | 16 +- .../cart/ProductCard/ProductCardFooter.tsx | 11 +- src/ui/components/cart/ShoppingCart.tsx | 32 +- src/ui/components/cart/ShoppingCartItem.tsx | 25 +- 32 files changed, 812 insertions(+), 698 deletions(-) create mode 100644 src/domain/cart/CartItems.ts create mode 100644 src/domain/cart/__spec__/productFixtures.ts create mode 100644 src/domain/currency/GoldSilverCopperAmount.ts create mode 100644 src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts create mode 100644 src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts rename src/{ui/components/WelcomeModal/WelcomeStorage => domain/welcomeSurvey/infrastructure}/LocalStorageWelcomeSurvey.repository.ts (100%) rename src/{ui/components/WelcomeModal/WelcomeStorage => domain/welcomeSurvey/infrastructure}/TanstackStoreWelcomeSurvey.repository.ts (100%) rename src/{ui/components/WelcomeModal/WelcomeStorage => domain/welcomeSurvey/infrastructure}/ZustandWelcomeSurvey.repository.ts (100%) rename src/{ui/components/WelcomeModal/WelcomeStorage => domain/welcomeSurvey/infrastructure}/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts (100%) rename src/{ui/components/WelcomeModal/WelcomeStorage => domain/welcomeSurvey/infrastructure}/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts (100%) delete mode 100644 src/ui/components/cart/ProductCard/ProductCard.logic.ts diff --git a/src/config/analytics.config.ts b/src/config/analytics.config.ts index c588188..571b98c 100644 --- a/src/config/analytics.config.ts +++ b/src/config/analytics.config.ts @@ -13,6 +13,12 @@ export const ANALYTICS_CONFIG = { homeArticle: "home.article", homeGithub: "home.github", completePurchase: "shopping-cart.complete-purchase", + increaseQuantity: "shopping-cart.increase-quantity", + decreaseQuantity: "shopping-cart.decrease-quantity", + removeItem: "shopping-cart.remove-item", contactLinkClick: "contact.link-click", + addToCart: "product.add-to-cart", + watchVideo: "product.watch-video", + readLore: "product.read-lore", }, } as const; diff --git a/src/config/storage.config.ts b/src/config/storage.config.ts index 83b2427..0a83f13 100644 --- a/src/config/storage.config.ts +++ b/src/config/storage.config.ts @@ -1,5 +1,5 @@ +import { TanStackStoreWelcomeSurveyRepository } from "@/domain/welcomeSurvey/infrastructure/TanstackStoreWelcomeSurvey.repository"; import type { WelcomeStorageRepository } from "@/domain/welcomeSurvey/WelcomeSurveyStorage.repository"; -import { TanStackStoreWelcomeSurveyRepository } from "@/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository"; /** * The active WelcomeSurvey storage strategy. LocalStorageWelcomeSurveyRepository and diff --git a/src/data/products.ts b/src/data/products.ts index 923c7b6..33299dd 100644 --- a/src/data/products.ts +++ b/src/data/products.ts @@ -1,5 +1,6 @@ import { Money } from "@/domain/cart/value-objects/Money"; import { + ExtraResources, ImageUrl, KnowledgeContent, Product, @@ -8,220 +9,233 @@ import { } from "@/domain/cart/value-objects/Product/Product"; import { Effect, - ExtraResources, ProfileDetails, Stats, } from "@/domain/cart/value-objects/Product/ProductDetails"; -export const PRODUCTS: Product[] = [ - new Product( - new ProductId("thunderfury"), - new ProductName("Thunderfury, Blessed Blade of the Windseeker"), - new Money(115105), - new ImageUrl( +interface StatBlock { + power: number; + durability: number; + manaBoost: number; +} + +interface ProfileBlueprint { + description: string; + stats: StatBlock; + effects: string[]; +} + +interface ProductBlueprint { + id: string; + name: string; + priceInCopper: number; + imageUrl: string; + loreLink: string; + videoUrl: string; + beginner: ProfileBlueprint; + intermediate: ProfileBlueprint; + expert: ProfileBlueprint; +} + +function buildProfile(blueprint: ProfileBlueprint): ProfileDetails { + const { power, durability, manaBoost } = blueprint.stats; + + return new ProfileDetails( + blueprint.description, + new Stats(power, durability, manaBoost), + blueprint.effects.map((effect) => new Effect(effect)), + ); +} + +function buildProduct(blueprint: ProductBlueprint): Product { + const knowledge = new KnowledgeContent( + buildProfile(blueprint.beginner), + buildProfile(blueprint.intermediate), + buildProfile(blueprint.expert), + ); + + return new Product( + new ProductId(blueprint.id), + new ProductName(blueprint.name), + new Money(blueprint.priceInCopper), + new ImageUrl(blueprint.imageUrl), + knowledge, + new ExtraResources(blueprint.loreLink, blueprint.videoUrl), + ); +} + +/** + * First-class collection wrapping the shop's Product[], instead of a bare exported array. + */ +export class ProductCatalog { + private constructor(private readonly products: readonly Product[]) {} + + static of(blueprints: ProductBlueprint[]): ProductCatalog { + return new ProductCatalog(blueprints.map(buildProduct)); + } + + all(): readonly Product[] { + return this.products; + } + + findById(productId: string): Product | undefined { + return this.products.find((product) => product.displayId() === productId); + } +} + +export const PRODUCT_CATALOG = ProductCatalog.of([ + { + id: "thunderfury", + name: "Thunderfury, Blessed Blade of the Windseeker", + priceInCopper: 115105, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ), - new KnowledgeContent( - new ProfileDetails( + loreLink: + "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", + videoUrl: "https://www.youtube.com/embed/2TGRpvb5Nos", + beginner: { + description: "A legendary sword that channels the power of storms. Wield it to summon lightning upon your foes.", - new Stats(150, 50, 0), - [new Effect("Chance to strike enemies with lightning.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( + stats: { power: 150, durability: 50, manaBoost: 0 }, + effects: ["Chance to strike enemies with lightning."], + }, + intermediate: { + description: "Thunderfury is forged with elemental fury. Great for battle, with bonus lightning damage and attack speed.", - new Stats(300, 100, 10), - [ - new Effect("Increases attack speed by 15%"), - new Effect("Lightning damage with each hit"), - new Effect("Chance to slow enemies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( + stats: { power: 300, durability: 100, manaBoost: 10 }, + effects: [ + "Increases attack speed by 15%", + "Lightning damage with each hit", + "Chance to slow enemies", + ], + }, + expert: { + description: "Thunderfury, Blessed Blade of the Windseeker, is a legendary weapon of immense power. It channels elemental air, unleashing lightning with each strike. Its artifact status is confirmed by its rarity and crafting requirements.", - new Stats(500, 150, 25), - [ - new Effect("Attack speed +25%"), - new Effect("Unleashes chain lightning on critical strikes"), - new Effect("Chance to silence enemies"), - new Effect("Grants immunity to silence for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - ), - ), - new Product( - new ProductId("ashbringer"), - new ProductName("Ashbringer"), - new Money(288527), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/a/a6/Ashbringer_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( + stats: { power: 500, durability: 150, manaBoost: 25 }, + effects: [ + "Attack speed +25%", + "Unleashes chain lightning on critical strikes", + "Chance to silence enemies", + "Grants immunity to silence for 5 seconds", + ], + }, + }, + { + id: "ashbringer", + name: "Ashbringer", + priceInCopper: 288527, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/a/a6/Ashbringer_TCG.jpg", + loreLink: "https://wowpedia.fandom.com/wiki/Ashbringer", + videoUrl: "https://www.youtube.com/embed/lso8Ygk4rKc", + beginner: { + description: "A holy sword blessed to purge the undead. Known for its radiant power.", - new Stats(200, 70, 0), - [new Effect("Bonus against undead enemies.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( + stats: { power: 200, durability: 70, manaBoost: 0 }, + effects: ["Bonus against undead enemies."], + }, + intermediate: { + description: "Ashbringer channels holy energy to smite foes, cleansing corruption and undead.", - new Stats(350, 140, 20), - [new Effect("Holy damage +25%"), new Effect("Undead vulnerability.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( + stats: { power: 350, durability: 140, manaBoost: 20 }, + effects: ["Holy damage +25%", "Undead vulnerability."], + }, + expert: { + description: "Ashbringer is the embodiment of righteousness. It delivers devastating holy strikes and vanquishes evil.", - new Stats(600, 220, 40), - [ - new Effect("Holy damage +50%"), - new Effect("Instant kill chance on undead"), - new Effect("Grant blessing to allies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - ), - ), - new Product( - new ProductId("sulfuras"), - new ProductName("Sulfuras, Hand of Ragnaros"), - new Money(31244), - new ImageUrl( + stats: { power: 600, durability: 220, manaBoost: 40 }, + effects: [ + "Holy damage +50%", + "Instant kill chance on undead", + "Grant blessing to allies", + ], + }, + }, + { + id: "sulfuras", + name: "Sulfuras, Hand of Ragnaros", + priceInCopper: 31244, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/6/6e/Sulfuras_Hand_of_Ragnaros_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A molten hammer forged in elemental fire, granting immense strength.", - new Stats(220, 80, 0), - [new Effect("Fire damage over time.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", - "https://www.youtube.com/embed/crNEJBci-Es", - ), - ), - new ProfileDetails( + loreLink: "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", + videoUrl: "https://www.youtube.com/embed/crNEJBci-Es", + beginner: { + description: "A molten hammer forged in elemental fire, granting immense strength.", + stats: { power: 220, durability: 80, manaBoost: 0 }, + effects: ["Fire damage over time."], + }, + intermediate: { + description: "Sulfuras channels molten fire into devastating blows that incinerate enemies.", - new Stats(400, 160, 0), - [new Effect("Fire blast chance"), new Effect("Burn enemies for 10s")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", - "https://www.youtube.com/embed/crNEJBci-Es", - ), - ), - new ProfileDetails( + stats: { power: 400, durability: 160, manaBoost: 0 }, + effects: ["Fire blast chance", "Burn enemies for 10s"], + }, + expert: { + description: "Sulfuras is a weapon of pure elemental fury, capable of burning armies with a single strike.", - new Stats(700, 300, 0), - [ - new Effect("Fire damage +70%"), - new Effect("Area of effect burn"), - new Effect("Ignite enemies on critical strike"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Sulfuras,_Hand_of_Ragnaros", - "https://www.youtube.com/embed/crNEJBci-Es", - ), - ), - ), - ), - new Product( - new ProductId("valanyr"), - new ProductName("Val'anyr, Hammer of Ancient Kings"), - new Money(363651), - new ImageUrl( + stats: { power: 700, durability: 300, manaBoost: 0 }, + effects: [ + "Fire damage +70%", + "Area of effect burn", + "Ignite enemies on critical strike", + ], + }, + }, + { + id: "valanyr", + name: "Val'anyr, Hammer of Ancient Kings", + priceInCopper: 363651, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/7/76/Val%27anyr%2C_Hammer_of_Ancient_Kings_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A holy hammer that heals allies with each strike.", - new Stats(160, 60, 20), - [new Effect("Heals allies on hit.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", - "https://www.youtube.com/embed/u5GX0RHW6KM", - ), - ), - new ProfileDetails( - "Val'anyr channels holy energy to protect allies in battle.", - new Stats(320, 140, 40), - [new Effect("Heal over time for allies."), new Effect("Shield chance")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", - "https://www.youtube.com/embed/u5GX0RHW6KM", - ), - ), - new ProfileDetails( + loreLink: "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", + videoUrl: "https://www.youtube.com/embed/u5GX0RHW6KM", + beginner: { + description: "A holy hammer that heals allies with each strike.", + stats: { power: 160, durability: 60, manaBoost: 20 }, + effects: ["Heals allies on hit."], + }, + intermediate: { + description: "Val'anyr channels holy energy to protect allies in battle.", + stats: { power: 320, durability: 140, manaBoost: 40 }, + effects: ["Heal over time for allies.", "Shield chance"], + }, + expert: { + description: "Val'anyr is a divine relic that grants immense healing and protection to allies.", - new Stats(580, 220, 60), - [ - new Effect("Massive healing over time"), - new Effect("Full shield on critical hits"), - new Effect("Grant immunity to all debuffs for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Val'anyr,_Hammer_of_Ancient_Kings", - "https://www.youtube.com/embed/u5GX0RHW6KM", - ), - ), - ), - ), - new Product( - new ProductId("doomhammer"), - new ProductName("Doomhammer"), - new Money(363651), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/a/a2/Doomhammer_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A mighty hammer that channels elemental earth power.", - new Stats(170, 80, 10), - [new Effect("Shockwave on impact.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Doomhammer", - "https://www.youtube.com/embed/htTWpEk_XDk", - ), - ), - new ProfileDetails( - "Doomhammer enhances the wielder’s strength and earth-based magic.", - new Stats(340, 160, 20), - [new Effect("Shockwave radius +15%"), new Effect("Stun chance on heavy attacks")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Doomhammer", - "https://www.youtube.com/embed/htTWpEk_XDk", - ), - ), - new ProfileDetails( + stats: { power: 580, durability: 220, manaBoost: 60 }, + effects: [ + "Massive healing over time", + "Full shield on critical hits", + "Grant immunity to all debuffs for 5 seconds", + ], + }, + }, + { + id: "doomhammer", + name: "Doomhammer", + priceInCopper: 363651, + imageUrl: "https://static.wikia.nocookie.net/wowpedia/images/a/a2/Doomhammer_TCG.jpg", + loreLink: "https://wowpedia.fandom.com/wiki/Doomhammer", + videoUrl: "https://www.youtube.com/embed/htTWpEk_XDk", + beginner: { + description: "A mighty hammer that channels elemental earth power.", + stats: { power: 170, durability: 80, manaBoost: 10 }, + effects: ["Shockwave on impact."], + }, + intermediate: { + description: "Doomhammer enhances the wielder’s strength and earth-based magic.", + stats: { power: 340, durability: 160, manaBoost: 20 }, + effects: ["Shockwave radius +15%", "Stun chance on heavy attacks"], + }, + expert: { + description: "Doomhammer is the ultimate weapon of war, capable of shaking the very earth.", - new Stats(610, 300, 40), - [ - new Effect("Shockwave radius +50%"), - new Effect("Stun all enemies within range"), - new Effect("Damage immunity for 3 seconds after a heavy attack"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Doomhammer", - "https://www.youtube.com/embed/htTWpEk_XDk", - ), - ), - ), - ), -]; + stats: { power: 610, durability: 300, manaBoost: 40 }, + effects: [ + "Shockwave radius +50%", + "Stun all enemies within range", + "Damage immunity for 3 seconds after a heavy attack", + ], + }, + }, +]); diff --git a/src/domain/cart/Cart.ts b/src/domain/cart/Cart.ts index b6c83a7..a8b4ef8 100644 --- a/src/domain/cart/Cart.ts +++ b/src/domain/cart/Cart.ts @@ -1,33 +1,31 @@ +import { CartItems } from "@/domain/cart/CartItems"; +import { ShippingPolicy } from "@/domain/cart/policy/ShippingPolicy"; +import { TaxPolicy } from "@/domain/cart/policy/TaxPolicy"; import { Money } from "@/domain/cart/value-objects/Money"; import type { Product } from "@/domain/cart/value-objects/Product/Product"; -import { Quantity } from "@/domain/cart/value-objects/Quantity"; -import { CartItem } from "./CartItem"; -import { ShippingPolicy } from "./policy/ShippingPolicy"; -import { TaxPolicy } from "./policy/TaxPolicy"; +import type { Quantity } from "@/domain/cart/value-objects/Quantity"; +import type { CartItem } from "./CartItem"; /** * Immutable shopping cart that encapsulates its behavior and state. */ export class Cart { - private items: CartItem[] = []; - - constructor(items: CartItem[] = []) { - this.items = items; - } + constructor(private readonly items: CartItems = CartItems.empty()) {} isEmpty(): boolean { - return this.items.length === 0; + return this.items.isEmpty(); } totalItems(): Quantity { - return this.items.reduce( - (total, item) => total.add(item.getQuantity()), - new Quantity(0), - ); + return this.items.totalQuantity(); + } + + listItems(): readonly CartItem[] { + return this.items.toArray(); } calculateSubtotal(): Money { - return this.items.reduce((total, item) => total.add(item.totalPrice()), new Money(0)); + return this.items.totalPrice(); } calculateShipping(): Money { @@ -39,49 +37,33 @@ export class Cart { } calculateTotal(): Money { - const subtotal = this.calculateSubtotal(); - const tax = TaxPolicy.calculate(subtotal); - const shipping = ShippingPolicy.calculate(subtotal); - return subtotal.add(tax).add(shipping); - } - - getItemsCopy(): readonly CartItem[] { - return [...this.items]; + return this.calculateSubtotal() + .add(this.calculateTax()) + .add(this.calculateShipping()); } - addItem(product: Product): Cart { - const existing = this.items.find((item) => item.getId() === product.displayId()); + remainingForFreeShipping(): Money { + const subtotal = this.calculateSubtotal(); - if (existing) { - const updatedItem = existing.increaseQuantity(); - return new Cart( - this.items.map((item) => - item.getId() === existing.getId() ? updatedItem : item, - ), - ); + if (subtotal.isAtLeast(ShippingPolicy.SHIPPING_THRESHOLD)) { + return new Money(0); } - - const newItem = new CartItem(product, new Quantity(1)); - return new Cart([...this.items, newItem]); + return ShippingPolicy.SHIPPING_THRESHOLD.subtract(subtotal); } - decrementItem(product: Product): Cart { - const existing = this.items.find((item) => item.getId() === product.displayId()); - - if (!existing) return this; - - const updatedItem = existing.decreaseQuantity(); - - return new Cart( - this.items.map((item) => (item.getId() === existing.getId() ? updatedItem : item)), - ); + addItem(product: Product): Cart { + return new Cart(this.items.addOrIncrement(product)); } - deleteItem(product: Product): Cart { - const existing = this.items.find((item) => item.getId() === product.displayId()); + increaseQuantity(itemId: string): Cart { + return new Cart(this.items.increaseQuantity(itemId)); + } - if (!existing) return this; + decreaseQuantity(itemId: string): Cart { + return new Cart(this.items.decreaseQuantity(itemId)); + } - return new Cart(this.items.filter((item) => item.getId() !== product.displayId())); + removeItem(itemId: string): Cart { + return new Cart(this.items.remove(itemId)); } } diff --git a/src/domain/cart/CartItem.ts b/src/domain/cart/CartItem.ts index e60c455..70e7f05 100644 --- a/src/domain/cart/CartItem.ts +++ b/src/domain/cart/CartItem.ts @@ -1,80 +1,56 @@ import { COMMERCE_CONFIG } from "@/config/commerce.config"; -import { Money } from "@/domain/cart/value-objects/Money"; +import type { Money } from "@/domain/cart/value-objects/Money"; import type { Product } from "@/domain/cart/value-objects/Product/Product"; import { Quantity } from "@/domain/cart/value-objects/Quantity"; export class CartItem { - static readonly MAX_QUANTITY = COMMERCE_CONFIG.quantityPerItem.max; - static readonly MIN_QUANTITY = COMMERCE_CONFIG.quantityPerItem.min; + static readonly MAX_QUANTITY = new Quantity(COMMERCE_CONFIG.quantityPerItem.max); + static readonly MIN_QUANTITY = new Quantity(COMMERCE_CONFIG.quantityPerItem.min); constructor( - private readonly product: Product, - private readonly quantity: Quantity, + private readonly _product: Product, + private readonly _quantity: Quantity, ) {} - increaseQuantity(): CartItem { - const currentValue = this.quantity.toValue(); - - if (currentValue >= CartItem.MAX_QUANTITY) { - return this; - } - - const newQuantity = this.quantity.increment(); - return new CartItem(this.product, newQuantity); + id(): string { + return this._product.displayId(); } - decreaseQuantity(): CartItem { - const currentValue = this.quantity.toValue(); - - if (currentValue <= CartItem.MIN_QUANTITY) { - return this; - } - - const newQuantity = this.quantity.decrement(); - return new CartItem(this.product, newQuantity); + name(): string { + return this._product.displayName(); } - updateQuantity(newQuantity: Quantity): CartItem { - let value = newQuantity.toValue(); - - if (value < CartItem.MIN_QUANTITY) { - value = CartItem.MIN_QUANTITY; - } - - if (value > CartItem.MAX_QUANTITY) { - value = CartItem.MAX_QUANTITY; - } - - const clampedQuantity = new Quantity(value); - return new CartItem(this.product, clampedQuantity); - } - - totalPrice(): Money { - const pricePerUnit = new Money(this.product.displayPrice().toAmount()); - return pricePerUnit.multiply(this.quantity.toValue()); + image(): string { + return this._product.displayImage(); } - getId(): string { - return this.product.displayId(); + quantity(): Quantity { + return this._quantity; } - getName(): string { - return this.product.displayName(); - } - - getImage(): string { - return this.product.displayImage(); + totalPrice(): Money { + return this._product.displayPrice().multiply(this._quantity.toValue()); } - getQuantity(): Quantity { - return this.quantity; + increaseQuantity(): CartItem { + if (this._quantity.isAtLeast(CartItem.MAX_QUANTITY)) { + return this; + } + return new CartItem(this._product, this._quantity.increment()); } - getPrice(): Money { - return this.product.displayPrice(); + decreaseQuantity(): CartItem { + if (this._quantity.isAtMost(CartItem.MIN_QUANTITY)) { + return this; + } + return new CartItem(this._product, this._quantity.decrement()); } - getProduct(): Product { - return this.product; + updateQuantity(newQuantity: Quantity): CartItem { + const clamped = newQuantity.clampBetween( + CartItem.MIN_QUANTITY, + CartItem.MAX_QUANTITY, + ); + return new CartItem(this._product, clamped); } } diff --git a/src/domain/cart/CartItems.ts b/src/domain/cart/CartItems.ts new file mode 100644 index 0000000..f527712 --- /dev/null +++ b/src/domain/cart/CartItems.ts @@ -0,0 +1,75 @@ +import { CartItem } from "@/domain/cart/CartItem"; +import { Money } from "@/domain/cart/value-objects/Money"; +import type { Product } from "@/domain/cart/value-objects/Product/Product"; +import { Quantity } from "@/domain/cart/value-objects/Quantity"; + +/** + * First-class collection wrapping the cart's CartItem[]. Cart delegates every + * find/map/filter/reduce over its items here instead of doing them inline. + */ +export class CartItems { + private constructor(private readonly items: readonly CartItem[]) {} + + static empty(): CartItems { + return new CartItems([]); + } + + isEmpty(): boolean { + return this.items.length === 0; + } + + toArray(): readonly CartItem[] { + return this.items; + } + + totalQuantity(): Quantity { + return this.items.reduce( + (total, item) => total.add(item.quantity()), + new Quantity(0), + ); + } + + totalPrice(): Money { + return this.items.reduce((total, item) => total.add(item.totalPrice()), new Money(0)); + } + + addOrIncrement(product: Product): CartItems { + const existing = this.find(product.displayId()); + + if (existing) { + return this.replace(existing.increaseQuantity()); + } + return new CartItems([...this.items, new CartItem(product, new Quantity(1))]); + } + + increaseQuantity(itemId: string): CartItems { + return this.updateItem(itemId, (item) => item.increaseQuantity()); + } + + decreaseQuantity(itemId: string): CartItems { + return this.updateItem(itemId, (item) => item.decreaseQuantity()); + } + + remove(itemId: string): CartItems { + return new CartItems(this.items.filter((item) => item.id() !== itemId)); + } + + private find(itemId: string): CartItem | undefined { + return this.items.find((item) => item.id() === itemId); + } + + private replace(updatedItem: CartItem): CartItems { + return new CartItems( + this.items.map((item) => (item.id() === updatedItem.id() ? updatedItem : item)), + ); + } + + private updateItem(itemId: string, update: (item: CartItem) => CartItem): CartItems { + const existing = this.find(itemId); + + if (!existing) { + return this; + } + return this.replace(update(existing)); + } +} diff --git a/src/domain/cart/__spec__/Cart.spec.ts b/src/domain/cart/__spec__/Cart.spec.ts index e0d3334..d129981 100644 --- a/src/domain/cart/__spec__/Cart.spec.ts +++ b/src/domain/cart/__spec__/Cart.spec.ts @@ -1,108 +1,13 @@ import { describe, expect, it } from "vitest"; -import { Money } from "@/domain/cart/value-objects/Money"; -import { - ImageUrl, - KnowledgeContent, - Product, - ProductId, - ProductName, -} from "@/domain/cart/value-objects/Product/Product"; -import { - Effect, - ExtraResources, - ProfileDetails, - Stats, -} from "@/domain/cart/value-objects/Product/ProductDetails"; import { Cart } from "../Cart"; +import { aProduct } from "./productFixtures"; -const PRODUCT_1 = new Product( - new ProductId("thunderfury"), - new ProductName("Thunderfury, Blessed Blade of the Windseeker"), - new Money(485), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ), - new KnowledgeContent( - new ProfileDetails( - "A legendary sword that channels the power of storms. Wield it to summon lightning upon your foes.", - new Stats(150, 50, 0), - [new Effect("Chance to strike enemies with lightning.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury is forged with elemental fury. Great for battle, with bonus lightning damage and attack speed.", - new Stats(300, 100, 10), - [ - new Effect("Increases attack speed by 15%"), - new Effect("Lightning damage with each hit"), - new Effect("Chance to slow enemies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury, Blessed Blade of the Windseeker, is a legendary weapon of immense power. It channels elemental air, unleashing lightning with each strike. Its artifact status is confirmed by its rarity and crafting requirements.", - new Stats(500, 150, 25), - [ - new Effect("Attack speed +25%"), - new Effect("Unleashes chain lightning on critical strikes"), - new Effect("Chance to silence enemies"), - new Effect("Grants immunity to silence for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - ), -); - -const PRODUCT_2 = new Product( - new ProductId("ashbringer"), - new ProductName("Ashbringer"), - new Money(750), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/a/a6/Ashbringer_TCG.jpg", - ), - new KnowledgeContent( - new ProfileDetails( - "A holy sword blessed to purge the undead. Known for its radiant power.", - new Stats(200, 70, 0), - [new Effect("Bonus against undead enemies.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( - "Ashbringer channels holy energy to smite foes, cleansing corruption and undead.", - new Stats(350, 140, 20), - [new Effect("Holy damage +25%"), new Effect("Undead vulnerability.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - new ProfileDetails( - "Ashbringer is the embodiment of righteousness. It delivers devastating holy strikes and vanquishes evil.", - new Stats(600, 220, 40), - [ - new Effect("Holy damage +50%"), - new Effect("Instant kill chance on undead"), - new Effect("Grant blessing to allies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Ashbringer", - "https://www.youtube.com/embed/lso8Ygk4rKc", - ), - ), - ), -); +const PRODUCT_1 = aProduct({ + id: "thunderfury", + name: "Thunderfury, Blessed Blade of the Windseeker", + price: 485, +}); +const PRODUCT_2 = aProduct({ id: "ashbringer", name: "Ashbringer", price: 750 }); describe("Cart", () => { it("should be empty when initialized without items", () => { @@ -115,11 +20,10 @@ describe("Cart", () => { const cartAfterAdd = cart.addItem(PRODUCT_1); expect(cartAfterAdd.isEmpty()).toBe(false); - expect(cartAfterAdd.getItemsCopy().length).toBe(1); - const [addedItem] = cartAfterAdd.getItemsCopy(); + const [addedItem] = cartAfterAdd.listItems(); expect(addedItem).toBeDefined(); - expect(addedItem?.getId()).toBe("thunderfury"); - expect(addedItem?.getQuantity().toValue()).toBe(1); + expect(addedItem?.id()).toBe("thunderfury"); + expect(addedItem?.quantity().toValue()).toBe(1); }); it("should increment quantity if product already exists", () => { @@ -127,36 +31,52 @@ describe("Cart", () => { cart = cart.addItem(PRODUCT_1); cart = cart.addItem(PRODUCT_1); - const [item] = cart.getItemsCopy(); - expect(item?.getQuantity().toValue()).toBe(2); + const [item] = cart.listItems(); + expect(item?.quantity().toValue()).toBe(2); + }); + + it("should increase quantity of an existing item by id", () => { + let cart = new Cart(); + cart = cart.addItem(PRODUCT_1); + cart = cart.increaseQuantity("thunderfury"); + + const [item] = cart.listItems(); + expect(item?.quantity().toValue()).toBe(2); }); - it("should decrement item quantity", () => { + it("should decrease quantity of an existing item by id", () => { let cart = new Cart(); cart = cart.addItem(PRODUCT_1); cart = cart.addItem(PRODUCT_1); - const cartAfterDecrement = cart.decrementItem(PRODUCT_1); - const [item] = cartAfterDecrement.getItemsCopy(); - expect(item?.getQuantity().toValue()).toBe(1); + const cartAfterDecrease = cart.decreaseQuantity("thunderfury"); + const [item] = cartAfterDecrease.listItems(); + expect(item?.quantity().toValue()).toBe(1); }); it("should not decrement below MIN_QUANTITY", () => { const cart = new Cart().addItem(PRODUCT_1); - const cartAfterDecrement = cart.decrementItem(PRODUCT_1); - const [item] = cartAfterDecrement.getItemsCopy(); - expect(item?.getQuantity().toValue()).toBe(1); + const cartAfterDecrease = cart.decreaseQuantity("thunderfury"); + const [item] = cartAfterDecrease.listItems(); + expect(item?.quantity().toValue()).toBe(1); + }); + + it("should do nothing when decreasing or increasing an id that isn't in the cart", () => { + const cart = new Cart().addItem(PRODUCT_1); + + expect(cart.decreaseQuantity("unknown").listItems()).toHaveLength(1); + expect(cart.increaseQuantity("unknown").listItems()).toHaveLength(1); }); - it("should delete item from cart", () => { + it("should remove an item from the cart by id", () => { let cart = new Cart(); cart = cart.addItem(PRODUCT_1); cart = cart.addItem(PRODUCT_2); - const cartAfterDelete = cart.deleteItem(PRODUCT_1); - const [remainingItem] = cartAfterDelete.getItemsCopy(); - expect(cartAfterDelete.getItemsCopy().length).toBe(1); - expect(remainingItem?.getId()).toBe("ashbringer"); + const cartAfterRemoval = cart.removeItem("thunderfury"); + const [remainingItem] = cartAfterRemoval.listItems(); + expect(cartAfterRemoval.listItems()).toHaveLength(1); + expect(remainingItem?.id()).toBe("ashbringer"); }); it("should calculate subtotal correctly", () => { @@ -195,4 +115,16 @@ describe("Cart", () => { expect(tax).toBeCloseTo(86.45); expect(total).toBeCloseTo(1321.45); }); + + it("should report how much is left to reach free shipping", () => { + const cart = new Cart().addItem(aProduct({ id: "cheap", price: 100 })); + + expect(cart.remainingForFreeShipping().toAmount()).toBe(200); + }); + + it("should report nothing left once the free shipping threshold is met", () => { + const cart = new Cart().addItem(aProduct({ id: "expensive", price: 500 })); + + expect(cart.remainingForFreeShipping().toAmount()).toBe(0); + }); }); diff --git a/src/domain/cart/__spec__/CartItem.spec.ts b/src/domain/cart/__spec__/CartItem.spec.ts index aa238e2..3f791b1 100644 --- a/src/domain/cart/__spec__/CartItem.spec.ts +++ b/src/domain/cart/__spec__/CartItem.spec.ts @@ -1,126 +1,70 @@ import { describe, expect, it } from "vitest"; -import { Money } from "@/domain/cart/value-objects/Money"; -import { - ImageUrl, - KnowledgeContent, - Product, - ProductId, - ProductName, -} from "@/domain/cart/value-objects/Product/Product"; -import { - Effect, - ExtraResources, - ProfileDetails, - Stats, -} from "@/domain/cart/value-objects/Product/ProductDetails"; import { Quantity } from "@/domain/cart/value-objects/Quantity"; import { CartItem } from "../CartItem"; +import { aProduct } from "./productFixtures"; -const PRODUCT = new Product( - new ProductId("thunderfury"), - new ProductName("Thunderfury, Blessed Blade of the Windseeker"), - new Money(485), - new ImageUrl( - "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ), - new KnowledgeContent( - new ProfileDetails( - "A legendary sword that channels the power of storms. Wield it to summon lightning upon your foes.", - new Stats(150, 50, 0), - [new Effect("Chance to strike enemies with lightning.")], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury is forged with elemental fury. Great for battle, with bonus lightning damage and attack speed.", - new Stats(300, 100, 10), - [ - new Effect("Increases attack speed by 15%"), - new Effect("Lightning damage with each hit"), - new Effect("Chance to slow enemies"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - new ProfileDetails( - "Thunderfury, Blessed Blade of the Windseeker, is a legendary weapon of immense power. It channels elemental air, unleashing lightning with each strike. Its artifact status is confirmed by its rarity and crafting requirements.", - new Stats(500, 150, 25), - [ - new Effect("Attack speed +25%"), - new Effect("Unleashes chain lightning on critical strikes"), - new Effect("Chance to silence enemies"), - new Effect("Grants immunity to silence for 5 seconds"), - ], - new ExtraResources( - "https://wowpedia.fandom.com/wiki/Thunderfury,_Blessed_Blade_of_the_Windseeker", - "https://www.youtube.com/embed/2TGRpvb5Nos", - ), - ), - ), -); +const PRODUCT = aProduct({ + id: "thunderfury", + name: "Thunderfury, Blessed Blade of the Windseeker", + price: 485, + imageUrl: "https://example.com/thunderfury.jpg", +}); describe("CartItem", () => { - // const product = PRODUCT; const initialQuantity = new Quantity(5); const cartItem = new CartItem(PRODUCT, initialQuantity); it("should return correct id, name, image and quantity", () => { - expect(cartItem.getId()).toBe("thunderfury"); - expect(cartItem.getName()).toBe("Thunderfury, Blessed Blade of the Windseeker"); - expect(cartItem.getImage()).toBe( - "https://static.wikia.nocookie.net/wowpedia/images/c/c9/Thunderfury%2C_Blessed_Blade_of_the_Windseeker.JPG", - ); - expect(cartItem.getQuantity().toValue()).toBe(5); + expect(cartItem.id()).toBe("thunderfury"); + expect(cartItem.name()).toBe("Thunderfury, Blessed Blade of the Windseeker"); + expect(cartItem.image()).toBe("https://example.com/thunderfury.jpg"); + expect(cartItem.quantity().toValue()).toBe(5); }); it("should increase quantity correctly up to MAX_QUANTITY", () => { const increased = cartItem.increaseQuantity(); - expect(increased.getQuantity().toValue()).toBe(6); + expect(increased.quantity().toValue()).toBe(6); // Increase to max let maxed = increased; for (let i = 0; i < 10; i++) { maxed = maxed.increaseQuantity(); } - expect(maxed.getQuantity().toValue()).toBe(CartItem.MAX_QUANTITY); + expect(maxed.quantity().toValue()).toBe(CartItem.MAX_QUANTITY.toValue()); // Cannot exceed MAX_QUANTITY const stillMax = maxed.increaseQuantity(); - expect(stillMax.getQuantity().toValue()).toBe(CartItem.MAX_QUANTITY); + expect(stillMax.quantity().toValue()).toBe(CartItem.MAX_QUANTITY.toValue()); }); it("should decrease quantity correctly down to MIN_QUANTITY", () => { const decreased = cartItem.decreaseQuantity(); - expect(decreased.getQuantity().toValue()).toBe(4); + expect(decreased.quantity().toValue()).toBe(4); // Decrease to min let minItem = new CartItem(PRODUCT, new Quantity(3)); for (let i = 0; i < 10; i++) { minItem = minItem.decreaseQuantity(); } - expect(minItem.getQuantity().toValue()).toBe(CartItem.MIN_QUANTITY); + expect(minItem.quantity().toValue()).toBe(CartItem.MIN_QUANTITY.toValue()); // Cannot go below MIN_QUANTITY const stillMin = minItem.decreaseQuantity(); - expect(stillMin.getQuantity().toValue()).toBe(CartItem.MIN_QUANTITY); + expect(stillMin.quantity().toValue()).toBe(CartItem.MIN_QUANTITY.toValue()); }); it("should update quantity and clamp between min and max", () => { const tooLow = new Quantity(0); const updatedLow = cartItem.updateQuantity(tooLow); - expect(updatedLow.getQuantity().toValue()).toBe(CartItem.MIN_QUANTITY); + expect(updatedLow.quantity().toValue()).toBe(CartItem.MIN_QUANTITY.toValue()); const tooHigh = new Quantity(50); const updatedHigh = cartItem.updateQuantity(tooHigh); - expect(updatedHigh.getQuantity().toValue()).toBe(CartItem.MAX_QUANTITY); + expect(updatedHigh.quantity().toValue()).toBe(CartItem.MAX_QUANTITY.toValue()); const valid = new Quantity(7); const updatedValid = cartItem.updateQuantity(valid); - expect(updatedValid.getQuantity().toValue()).toBe(7); + expect(updatedValid.quantity().toValue()).toBe(7); }); it("should calculate total price correctly", () => { diff --git a/src/domain/cart/__spec__/productFixtures.ts b/src/domain/cart/__spec__/productFixtures.ts new file mode 100644 index 0000000..705acee --- /dev/null +++ b/src/domain/cart/__spec__/productFixtures.ts @@ -0,0 +1,36 @@ +import { Money } from "@/domain/cart/value-objects/Money"; +import { + ExtraResources, + ImageUrl, + KnowledgeContent, + Product, + ProductId, + ProductName, +} from "@/domain/cart/value-objects/Product/Product"; +import { + Effect, + ProfileDetails, + Stats, +} from "@/domain/cart/value-objects/Product/ProductDetails"; + +/** + * A minimal, valid Product for domain tests that don't care about its knowledge content, + * only its identity and price. Cart.spec.ts and CartItem.spec.ts both used to build this + * by hand, repeating the same five-level nesting for every fixture. + */ +export function aProduct( + overrides: Partial<{ id: string; name: string; price: number; imageUrl: string }> = {}, +): Product { + const profile = new ProfileDetails("A test product profile.", new Stats(1, 1, 1), [ + new Effect("Test effect"), + ]); + + return new Product( + new ProductId(overrides.id ?? "test-product"), + new ProductName(overrides.name ?? "Test Product"), + new Money(overrides.price ?? 100), + new ImageUrl(overrides.imageUrl ?? "https://example.com/image.png"), + new KnowledgeContent(profile, profile, profile), + new ExtraResources("https://example.com/lore", "https://example.com/video"), + ); +} diff --git a/src/domain/cart/policy/ShippingPolicy.ts b/src/domain/cart/policy/ShippingPolicy.ts index 1741f43..187aaa6 100644 --- a/src/domain/cart/policy/ShippingPolicy.ts +++ b/src/domain/cart/policy/ShippingPolicy.ts @@ -6,7 +6,7 @@ export class ShippingPolicy { static readonly SHIPPING_COST = new Money(COMMERCE_CONFIG.shippingCost); static calculate(subtotal: Money): Money { - return subtotal.toAmount() >= ShippingPolicy.SHIPPING_THRESHOLD.toAmount() + return subtotal.isAtLeast(ShippingPolicy.SHIPPING_THRESHOLD) ? new Money(0) : ShippingPolicy.SHIPPING_COST; } diff --git a/src/domain/cart/value-objects/Money.ts b/src/domain/cart/value-objects/Money.ts index cfb19dd..57ead98 100644 --- a/src/domain/cart/value-objects/Money.ts +++ b/src/domain/cart/value-objects/Money.ts @@ -1,5 +1,5 @@ export class Money { - constructor(private _amount: number) { + constructor(private readonly _amount: number) { if (_amount < 0) { throw new Error("Money amount cannot be negative"); } @@ -23,6 +23,14 @@ export class Money { return new Money(this._amount * factor); } + isAtLeast(other: Money): boolean { + return this._amount >= other._amount; + } + + isZero(): boolean { + return this._amount === 0; + } + toAmount(): number { return this._amount; } diff --git a/src/domain/cart/value-objects/Product/Product.ts b/src/domain/cart/value-objects/Product/Product.ts index 8ce8250..32094ab 100644 --- a/src/domain/cart/value-objects/Product/Product.ts +++ b/src/domain/cart/value-objects/Product/Product.ts @@ -1,5 +1,6 @@ +import type { SkillValue } from "@/domain/welcomeSurvey/value-objects/Skill"; import type { Money } from "../Money"; -import type { ProfileDetails } from "./ProductDetails"; +import type { NamedStat, ProfileDetails } from "./ProductDetails"; export class ProductId { constructor(private readonly _value: string) { @@ -31,40 +32,53 @@ export class ImageUrl { } } -export type KnowledgeProfile = "beginner" | "adventurer" | "expert"; +/** + * A product's lore page and video, shared across every knowledge level: they describe + * the item itself, not a skill-specific rendering of it. + */ +export class ExtraResources { + constructor( + private readonly _loreLink: string, + private readonly _videoUrl: string, + ) {} + + getLoreLink(): string { + return this._loreLink; + } + + getVideoUrl(): string { + return this._videoUrl; + } +} + +/** + * The same vocabulary as WelcomeSurvey's SkillValue: a shopper's stated skill and the + * knowledge tier a product's content is written for are the same concept. + */ +export type KnowledgeProfile = SkillValue; export class KnowledgeContent { constructor( private readonly _beginnerProfile: ProfileDetails, - private readonly _adventurerProfile: ProfileDetails, + private readonly _intermediateProfile: ProfileDetails, private readonly _expertProfile: ProfileDetails, ) {} - getProfileDescription(level: KnowledgeProfile): string { + describeProfile(level: KnowledgeProfile): string { return this.getProfile(level).describe(); } - getProfileEffects(level: KnowledgeProfile): string[] { + listProfileEffects(level: KnowledgeProfile): string[] { return this.getProfile(level).listEffects(); } - getProfileStats( - level: KnowledgeProfile, - ): Record<"power" | "durability" | "manaBoost", number> { - return this.getProfile(level).getStats(); - } - - getProfileLoreLink(level: KnowledgeProfile): string { - return this.getProfile(level).getLoreLink(); - } - - getProfileVideoUrl(level: KnowledgeProfile): string { - return this.getProfile(level).getVideoUrl(); + listProfileStats(level: KnowledgeProfile): NamedStat[] { + return this.getProfile(level).listStats(); } private getProfile(level: KnowledgeProfile): ProfileDetails { if (level === "beginner") return this._beginnerProfile; - if (level === "adventurer") return this._adventurerProfile; + if (level === "intermediate") return this._intermediateProfile; return this._expertProfile; } } @@ -76,6 +90,7 @@ export class Product { private readonly _price: Money, private readonly _imageUrl: ImageUrl, private readonly _knowledge: KnowledgeContent, + private readonly _extraResources: ExtraResources, ) {} displayId(): string { @@ -95,24 +110,22 @@ export class Product { } describeProfile(level: KnowledgeProfile): string { - return this._knowledge.getProfileDescription(level); + return this._knowledge.describeProfile(level); } listProfileEffects(level: KnowledgeProfile): string[] { - return this._knowledge.getProfileEffects(level); + return this._knowledge.listProfileEffects(level); } - getProfileStats( - level: KnowledgeProfile, - ): Record<"power" | "durability" | "manaBoost", number> { - return this._knowledge.getProfileStats(level); + listProfileStats(level: KnowledgeProfile): NamedStat[] { + return this._knowledge.listProfileStats(level); } - getLoreLink(level: KnowledgeProfile): string { - return this._knowledge.getProfileLoreLink(level); + getLoreLink(): string { + return this._extraResources.getLoreLink(); } - getVideoUrl(level: KnowledgeProfile): string { - return this._knowledge.getProfileVideoUrl(level); + getVideoUrl(): string { + return this._extraResources.getVideoUrl(); } } diff --git a/src/domain/cart/value-objects/Product/ProductDetails.ts b/src/domain/cart/value-objects/Product/ProductDetails.ts index 21307f1..46cbf2f 100644 --- a/src/domain/cart/value-objects/Product/ProductDetails.ts +++ b/src/domain/cart/value-objects/Product/ProductDetails.ts @@ -6,6 +6,11 @@ export class Effect { } } +export interface NamedStat { + label: string; + value: number; +} + export class Stats { constructor( private readonly _power: number, @@ -21,55 +26,37 @@ export class Stats { return new Stats(this._power + amount, this._durability, this._manaBoost); } - asObject(): Record<"power" | "durability" | "manaBoost", number> { - return { - power: this._power, - durability: this._durability, - manaBoost: this._manaBoost, - }; - } -} - -export class ExtraResources { - constructor( - private readonly _loreLink: string, - private readonly _videoUrl: string, - ) {} - - getLoreLink(): string { - return this._loreLink; - } - - getVideoUrl(): string { - return this._videoUrl; + /** Only the stats worth showing: zero-value ones are omitted rather than rendered as 0. */ + list(): NamedStat[] { + return [ + { label: "Power", value: this._power }, + { label: "Durability", value: this._durability }, + { label: "Mana Boost", value: this._manaBoost }, + ].filter((stat) => stat.value > 0); } } +/** + * The description, stats, and effects for one knowledge level of a product. A product's + * lore link and video live on Product itself (see ExtraResources), they don't vary by + * knowledge level. + */ export class ProfileDetails { constructor( private readonly _description: string, private readonly _stats: Stats, private readonly _effects: Effect[], - private readonly _extraResources: ExtraResources, ) {} describe(): string { return this._description; } - getStats(): Record<"power" | "durability" | "manaBoost", number> { - return this._stats.asObject(); + listStats(): NamedStat[] { + return this._stats.list(); } listEffects(): string[] { return this._effects.map((effect) => effect.toValue()); } - - getLoreLink(): string { - return this._extraResources.getLoreLink(); - } - - getVideoUrl(): string { - return this._extraResources.getVideoUrl(); - } } diff --git a/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts b/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts index ee97d60..f5b3c85 100644 --- a/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts +++ b/src/domain/cart/value-objects/Product/__spec__/Product.spec.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { Money } from "../../Money"; -import { ImageUrl, KnowledgeContent, Product, ProductId, ProductName } from "../Product"; -import { Effect, ExtraResources, ProfileDetails, Stats } from "../ProductDetails"; +import { + ExtraResources, + ImageUrl, + KnowledgeContent, + Product, + ProductId, + ProductName, +} from "../Product"; +import { Effect, ProfileDetails, Stats } from "../ProductDetails"; describe("Product Value Objects", () => { it("should create a ProductId and return value", () => { @@ -21,28 +28,31 @@ describe("Product Value Objects", () => { expect(imageUrl.toValue()).toBe("http://image.link"); expect(() => new ImageUrl("")).toThrow("Image URL cannot be empty"); }); + + it("should return the lore link and video URL", () => { + const resources = new ExtraResources("https://lore.link", "https://video.link"); + expect(resources.getLoreLink()).toBe("https://lore.link"); + expect(resources.getVideoUrl()).toBe("https://video.link"); + }); }); describe("KnowledgeContent and Product", () => { const statsByLevel = { beginner: { power: 1, durability: 2, manaBoost: 3 }, - adventurer: { power: 4, durability: 5, manaBoost: 6 }, + intermediate: { power: 4, durability: 5, manaBoost: 6 }, expert: { power: 7, durability: 8, manaBoost: 9 }, } as const; - const profileFor = (level: "beginner" | "adventurer" | "expert") => { + const profileFor = (level: "beginner" | "intermediate" | "expert") => { const { power, durability, manaBoost } = statsByLevel[level]; - return new ProfileDetails( - `${level} tale`, - new Stats(power, durability, manaBoost), - [new Effect(`${level} effect`)], - new ExtraResources(`https://lore.link/${level}`, `https://video.link/${level}`), - ); + return new ProfileDetails(`${level} tale`, new Stats(power, durability, manaBoost), [ + new Effect(`${level} effect`), + ]); }; const knowledge = new KnowledgeContent( profileFor("beginner"), - profileFor("adventurer"), + profileFor("intermediate"), profileFor("expert"), ); @@ -52,6 +62,7 @@ describe("KnowledgeContent and Product", () => { new Money(500), new ImageUrl("http://image.link"), knowledge, + new ExtraResources("https://lore.link/excalibur", "https://video.link/excalibur"), ); it("should return correct product info", () => { @@ -61,14 +72,23 @@ describe("KnowledgeContent and Product", () => { expect(product.displayImage()).toBe("http://image.link"); }); - it.each(["beginner", "adventurer", "expert"] as const)( - "should route %s to its own profile's description, effects, stats, lore link, and video URL", + it("should return the same lore link and video URL regardless of knowledge level", () => { + expect(product.getLoreLink()).toBe("https://lore.link/excalibur"); + expect(product.getVideoUrl()).toBe("https://video.link/excalibur"); + }); + + it.each(["beginner", "intermediate", "expert"] as const)( + "should route %s to its own profile's description, effects, and stats", (level) => { + const { power, durability, manaBoost } = statsByLevel[level]; + expect(product.describeProfile(level)).toBe(`${level} tale`); expect(product.listProfileEffects(level)).toEqual([`${level} effect`]); - expect(product.getProfileStats(level)).toEqual(statsByLevel[level]); - expect(product.getLoreLink(level)).toBe(`https://lore.link/${level}`); - expect(product.getVideoUrl(level)).toBe(`https://video.link/${level}`); + expect(product.listProfileStats(level)).toEqual([ + { label: "Power", value: power }, + { label: "Durability", value: durability }, + { label: "Mana Boost", value: manaBoost }, + ]); }, ); }); diff --git a/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts b/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts index 287ce45..fe91eb3 100644 --- a/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts +++ b/src/domain/cart/value-objects/Product/__spec__/ProductDetails.spec.ts @@ -1,35 +1,26 @@ import { describe, expect, it } from "vitest"; -import { Effect, ExtraResources, ProfileDetails, Stats } from "../ProductDetails"; +import { Effect, ProfileDetails, Stats } from "../ProductDetails"; describe("ProfileDetails", () => { const stats = new Stats(10, 20, 5); const effects = [new Effect("Fire damage"), new Effect("Stun enemies")]; - const extraResources = new ExtraResources("https://lore.link", "https://video.link"); - const profile = new ProfileDetails("Test description", stats, effects, extraResources); + const profile = new ProfileDetails("Test description", stats, effects); it("should return the correct description", () => { expect(profile.describe()).toBe("Test description"); }); - it("should return stats as object", () => { - expect(profile.getStats()).toEqual({ - power: 10, - durability: 20, - manaBoost: 5, - }); + it("should return the non-zero stats", () => { + expect(profile.listStats()).toEqual([ + { label: "Power", value: 10 }, + { label: "Durability", value: 20 }, + { label: "Mana Boost", value: 5 }, + ]); }); it("should return a list of effect values", () => { expect(profile.listEffects()).toEqual(["Fire damage", "Stun enemies"]); }); - - it("should return the correct lore link", () => { - expect(profile.getLoreLink()).toBe("https://lore.link"); - }); - - it("should return the correct video URL", () => { - expect(profile.getVideoUrl()).toBe("https://video.link"); - }); }); describe("Stats", () => { @@ -40,16 +31,25 @@ describe("Stats", () => { it("should increase power correctly", () => { const originalStats = new Stats(5, 10, 15); const newStats = originalStats.increasePower(7); - expect(newStats.asObject()).toEqual({ - power: 12, - durability: 10, - manaBoost: 15, - }); + expect(newStats.list()).toEqual([ + { label: "Power", value: 12 }, + { label: "Durability", value: 10 }, + { label: "Mana Boost", value: 15 }, + ]); }); - it("should return stats as an object", () => { + it("should list every stat that is greater than zero", () => { const stats = new Stats(3, 6, 9); - expect(stats.asObject()).toEqual({ power: 3, durability: 6, manaBoost: 9 }); + expect(stats.list()).toEqual([ + { label: "Power", value: 3 }, + { label: "Durability", value: 6 }, + { label: "Mana Boost", value: 9 }, + ]); + }); + + it("should omit stats that are zero", () => { + const stats = new Stats(3, 0, 0); + expect(stats.list()).toEqual([{ label: "Power", value: 3 }]); }); }); @@ -59,11 +59,3 @@ describe("Effect", () => { expect(effect.toValue()).toBe("Stun enemies"); }); }); - -describe("ExtraResources", () => { - it("should return the lore link and video URL", () => { - const resources = new ExtraResources("https://lore.link", "https://video.link"); - expect(resources.getLoreLink()).toBe("https://lore.link"); - expect(resources.getVideoUrl()).toBe("https://video.link"); - }); -}); diff --git a/src/domain/cart/value-objects/Quantity.ts b/src/domain/cart/value-objects/Quantity.ts index df85448..0e637f1 100644 --- a/src/domain/cart/value-objects/Quantity.ts +++ b/src/domain/cart/value-objects/Quantity.ts @@ -1,5 +1,5 @@ export class Quantity { - constructor(private _value: number) { + constructor(private readonly _value: number) { if (_value < 0) { throw new Error("Quantity cannot be negative"); } @@ -20,6 +20,20 @@ export class Quantity { return new Quantity(this._value - 1); } + clampBetween(min: Quantity, max: Quantity): Quantity { + if (this._value < min._value) return min; + if (this._value > max._value) return max; + return this; + } + + isAtLeast(other: Quantity): boolean { + return this._value >= other._value; + } + + isAtMost(other: Quantity): boolean { + return this._value <= other._value; + } + toValue(): number { return this._value; } diff --git a/src/domain/cart/value-objects/__spec__/Money.spec.ts b/src/domain/cart/value-objects/__spec__/Money.spec.ts index 8b7e149..86fa525 100644 --- a/src/domain/cart/value-objects/__spec__/Money.spec.ts +++ b/src/domain/cart/value-objects/__spec__/Money.spec.ts @@ -59,6 +59,17 @@ describe("Money", () => { expect(multipliedMoney.toAmount()).toBe(120); }); + it("should report whether it is at least another amount", () => { + expect(new Money(100).isAtLeast(new Money(100))).toBe(true); + expect(new Money(101).isAtLeast(new Money(100))).toBe(true); + expect(new Money(99).isAtLeast(new Money(100))).toBe(false); + }); + + it("should report whether it is zero", () => { + expect(new Money(0).isZero()).toBe(true); + expect(new Money(1).isZero()).toBe(false); + }); + it("should support chained operations", () => { const startingMoney = new Money(50); const resultMoney = startingMoney diff --git a/src/domain/cart/value-objects/__spec__/Quantity.spec.ts b/src/domain/cart/value-objects/__spec__/Quantity.spec.ts index feaee7c..4d2fe6f 100644 --- a/src/domain/cart/value-objects/__spec__/Quantity.spec.ts +++ b/src/domain/cart/value-objects/__spec__/Quantity.spec.ts @@ -38,6 +38,32 @@ describe("Quantity", () => { expect(() => zeroQuantity.decrement()).toThrowError("Quantity cannot be less than 0"); }); + it("should report whether it is at least or at most another quantity", () => { + expect(new Quantity(5).isAtLeast(new Quantity(5))).toBe(true); + expect(new Quantity(6).isAtLeast(new Quantity(5))).toBe(true); + expect(new Quantity(4).isAtLeast(new Quantity(5))).toBe(false); + + expect(new Quantity(5).isAtMost(new Quantity(5))).toBe(true); + expect(new Quantity(4).isAtMost(new Quantity(5))).toBe(true); + expect(new Quantity(6).isAtMost(new Quantity(5))).toBe(false); + }); + + it("should clamp to the minimum when below it", () => { + const clamped = new Quantity(0).clampBetween(new Quantity(1), new Quantity(10)); + expect(clamped.toValue()).toBe(1); + }); + + it("should clamp to the maximum when above it", () => { + const clamped = new Quantity(50).clampBetween(new Quantity(1), new Quantity(10)); + expect(clamped.toValue()).toBe(10); + }); + + it("should pass through unchanged when already within bounds", () => { + const withinBounds = new Quantity(7); + const clamped = withinBounds.clampBetween(new Quantity(1), new Quantity(10)); + expect(clamped).toBe(withinBounds); + }); + it("should be immutable (methods return a new instance)", () => { const baseQuantity = new Quantity(3); const incrementedQuantity = baseQuantity.increment(); diff --git a/src/domain/currency/GoldSilverCopperAmount.ts b/src/domain/currency/GoldSilverCopperAmount.ts new file mode 100644 index 0000000..3c96be6 --- /dev/null +++ b/src/domain/currency/GoldSilverCopperAmount.ts @@ -0,0 +1,50 @@ +import { COMMERCE_CONFIG } from "@/config/commerce.config"; +import type { Money } from "@/domain/cart/value-objects/Money"; + +const { copperPerSilver, silverPerGold } = COMMERCE_CONFIG.currency; +const copperPerGold = copperPerSilver * silverPerGold; + +/** + * The gold/silver/copper breakdown of a copper-denominated Money amount. Converting + * copper into denominations is this class's only job, formatting the result is + * GoldSilverCopperFormatter's. + */ +export class GoldSilverCopperAmount { + private constructor( + private readonly _gold: number, + private readonly _silver: number, + private readonly _copper: number, + ) {} + + static fromCopper(amount: Money): GoldSilverCopperAmount { + const safeCopper = Math.round(amount.toAmount()); + + const gold = Math.floor(safeCopper / copperPerGold); + const remainderAfterGold = safeCopper - gold * copperPerGold; + + const silver = Math.floor(remainderAfterGold / copperPerSilver); + const remainingCopper = remainderAfterGold - silver * copperPerSilver; + + return new GoldSilverCopperAmount(gold, silver, remainingCopper); + } + + gold(): number { + return this._gold; + } + + silver(): number { + return this._silver; + } + + copper(): number { + return this._copper; + } + + hasGold(): boolean { + return this._gold > 0; + } + + hasSilver(): boolean { + return this._silver > 0; + } +} diff --git a/src/domain/currency/GoldSilverCopperFormatter.ts b/src/domain/currency/GoldSilverCopperFormatter.ts index 5271074..0c102f4 100644 --- a/src/domain/currency/GoldSilverCopperFormatter.ts +++ b/src/domain/currency/GoldSilverCopperFormatter.ts @@ -1,36 +1,21 @@ -import { COMMERCE_CONFIG } from "@/config/commerce.config"; import type { Money } from "@/domain/cart/value-objects/Money"; - -const { copperPerSilver, silverPerGold } = COMMERCE_CONFIG.currency; -const copperPerGold = copperPerSilver * silverPerGold; +import { GoldSilverCopperAmount } from "@/domain/currency/GoldSilverCopperAmount"; /** - * Converts copper-based Money values into Azeroth-style currency - * (Gold, Silver, Copper) and formats them for display. + * Formats a copper-based Money value as Azeroth-style currency (Gold, Silver, Copper). + * Conversion itself lives in GoldSilverCopperAmount, this class only renders it. */ class GoldSilverCopperFormatter { - convertCopper(copper: Money) { - const safeCopper = Math.round(copper.toAmount()); - - const gold = Math.floor(safeCopper / copperPerGold); - const remainderAfterGold = safeCopper - gold * copperPerGold; - - const silver = Math.floor(remainderAfterGold / copperPerSilver); - const remainingCopper = remainderAfterGold - silver * copperPerSilver; - - return { gold, silver, copper: remainingCopper }; - } - format(amount: Money): string { - const { gold, silver, copper } = this.convertCopper(amount); - - const formatted: string[] = []; + const converted = GoldSilverCopperAmount.fromCopper(amount); + const parts: string[] = []; - if (gold > 0) formatted.push(`${gold} 🟡`); - if (silver > 0 || gold > 0) formatted.push(`${silver} ⚪`); - formatted.push(`${copper} 🟤`); + if (converted.hasGold()) parts.push(`${converted.gold()} 🟡`); + if (converted.hasSilver() || converted.hasGold()) + parts.push(`${converted.silver()} ⚪`); + parts.push(`${converted.copper()} 🟤`); - return formatted.join(" "); + return parts.join(" "); } } diff --git a/src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts b/src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts new file mode 100644 index 0000000..ec6aa5b --- /dev/null +++ b/src/domain/currency/__spec__/GoldSilverCopperAmount.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Money } from "@/domain/cart/value-objects/Money"; +import { GoldSilverCopperAmount } from "../GoldSilverCopperAmount"; + +describe("GoldSilverCopperAmount", () => { + it("should convert an amount under one silver into copper only", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(42)); + + expect(amount.gold()).toBe(0); + expect(amount.silver()).toBe(0); + expect(amount.copper()).toBe(42); + }); + + it("should convert an amount under one gold into silver and copper", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(1234)); + + expect(amount.gold()).toBe(0); + expect(amount.silver()).toBe(12); + expect(amount.copper()).toBe(34); + }); + + it("should convert a large amount into gold, silver, and copper", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(115105)); + + expect(amount.gold()).toBe(11); + expect(amount.silver()).toBe(51); + expect(amount.copper()).toBe(5); + }); + + it("should round fractional copper before converting", () => { + const amount = GoldSilverCopperAmount.fromCopper(new Money(99.6)); + + expect(amount.gold()).toBe(0); + expect(amount.silver()).toBe(1); + expect(amount.copper()).toBe(0); + }); + + it("should report whether it has gold or silver", () => { + expect(GoldSilverCopperAmount.fromCopper(new Money(0)).hasGold()).toBe(false); + expect(GoldSilverCopperAmount.fromCopper(new Money(0)).hasSilver()).toBe(false); + expect(GoldSilverCopperAmount.fromCopper(new Money(10000)).hasGold()).toBe(true); + expect(GoldSilverCopperAmount.fromCopper(new Money(100)).hasSilver()).toBe(true); + }); +}); diff --git a/src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts b/src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts new file mode 100644 index 0000000..48b6458 --- /dev/null +++ b/src/domain/currency/__spec__/GoldSilverCopperFormatter.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { Money } from "@/domain/cart/value-objects/Money"; +import { goldSilverCopperFormatter } from "../GoldSilverCopperFormatter"; + +describe("goldSilverCopperFormatter", () => { + it("should show only copper when there is no gold or silver", () => { + expect(goldSilverCopperFormatter.format(new Money(0))).toBe("0 🟤"); + expect(goldSilverCopperFormatter.format(new Money(42))).toBe("42 🟤"); + }); + + it("should show silver and copper once there is at least one silver", () => { + expect(goldSilverCopperFormatter.format(new Money(1234))).toBe("12 ⚪ 34 🟤"); + }); + + it("should show gold, silver, and copper for large amounts", () => { + expect(goldSilverCopperFormatter.format(new Money(115105))).toBe("11 🟡 51 ⚪ 5 🟤"); + }); + + it("should still show silver when it is 0 but gold is present", () => { + expect(goldSilverCopperFormatter.format(new Money(10005))).toBe("1 🟡 0 ⚪ 5 🟤"); + }); +}); diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts b/src/domain/welcomeSurvey/infrastructure/LocalStorageWelcomeSurvey.repository.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/LocalStorageWelcomeSurvey.repository.ts rename to src/domain/welcomeSurvey/infrastructure/LocalStorageWelcomeSurvey.repository.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts b/src/domain/welcomeSurvey/infrastructure/TanstackStoreWelcomeSurvey.repository.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/TanstackStoreWelcomeSurvey.repository.ts rename to src/domain/welcomeSurvey/infrastructure/TanstackStoreWelcomeSurvey.repository.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/ZustandWelcomeSurvey.repository.ts b/src/domain/welcomeSurvey/infrastructure/ZustandWelcomeSurvey.repository.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/ZustandWelcomeSurvey.repository.ts rename to src/domain/welcomeSurvey/infrastructure/ZustandWelcomeSurvey.repository.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts b/src/domain/welcomeSurvey/infrastructure/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts rename to src/domain/welcomeSurvey/infrastructure/__spec__/LocalStorageWelcomeSurvey.repository.spec.ts diff --git a/src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts b/src/domain/welcomeSurvey/infrastructure/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts similarity index 100% rename from src/ui/components/WelcomeModal/WelcomeStorage/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts rename to src/domain/welcomeSurvey/infrastructure/__spec__/TanstackStoreWelcomeSurvey.repository.spec.ts diff --git a/src/ui/components/cart/ProductCard/ProductCard.logic.ts b/src/ui/components/cart/ProductCard/ProductCard.logic.ts deleted file mode 100644 index dec1f8c..0000000 --- a/src/ui/components/cart/ProductCard/ProductCard.logic.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { KnowledgeProfile } from "@/domain/cart/value-objects/Product/Product"; -import type { WelcomeSurveyData } from "@/domain/welcomeSurvey/WelcomeSurvey.data"; - -export const ProductCardLogic = { - selectProfile: (skill: WelcomeSurveyData["skill"]): KnowledgeProfile => { - switch (skill) { - case "expert": - return "expert"; - case "intermediate": - return "adventurer"; - default: - return "beginner"; - } - }, -}; diff --git a/src/ui/components/cart/ProductCard/ProductCardContent.tsx b/src/ui/components/cart/ProductCard/ProductCardContent.tsx index 380347d..bf3da55 100644 --- a/src/ui/components/cart/ProductCard/ProductCardContent.tsx +++ b/src/ui/components/cart/ProductCard/ProductCardContent.tsx @@ -19,14 +19,14 @@ interface ProductCardContentProps { } const STAT_ICONS: Record = { - power: Swords, - durability: Shield, + Power: Swords, + Durability: Shield, }; export function ProductCardContent({ product, profile }: ProductCardContentProps) { const description = product.describeProfile(profile); const effects = product.listProfileEffects(profile); - const stats = product.getProfileStats(profile); + const stats = product.listProfileStats(profile); return ( @@ -35,22 +35,22 @@ export function ProductCardContent({ product, profile }: ProductCardContentProps {description}

- {profile !== "beginner" && ( + {profile !== "beginner" && stats.length > 0 && (

    - {Object.entries(stats).map(([key, value]) => { - const StatIcon = STAT_ICONS[key] ?? Wand2; + {stats.map(({ label, value }) => { + const StatIcon = STAT_ICONS[label] ?? Wand2; return ( -
  • +
  • ); })} diff --git a/src/ui/components/cart/ProductCard/ProductCardFooter.tsx b/src/ui/components/cart/ProductCard/ProductCardFooter.tsx index eac7e77..5b9a466 100644 --- a/src/ui/components/cart/ProductCard/ProductCardFooter.tsx +++ b/src/ui/components/cart/ProductCard/ProductCardFooter.tsx @@ -1,4 +1,5 @@ import { BookOpenText, Clapperboard, ShoppingCart } from "lucide-react"; +import { ANALYTICS_CONFIG } from "@/config/analytics.config"; import type { KnowledgeProfile, Product, @@ -25,15 +26,15 @@ export function ProductCardFooter({ profile, onAddToCart, }: ProductCardFooterProps) { - const loreLink = product.getLoreLink(profile); - const videoUrl = product.getVideoUrl(profile); + const loreLink = product.getLoreLink(); + const videoUrl = product.getVideoUrl(); return ( @@ -76,7 +77,7 @@ export function ShoppingCartItem({ className="px-3 py-1 min-w-[30px] text-center text-sm font-medium" aria-live="polite" > - {item.getQuantity().toValue()} + {item.quantity().toValue()} @@ -86,9 +87,9 @@ export function ShoppingCartItem({ variant="ghost" size="icon" className="h-9 w-9 p-0 rounded-l-none hover:bg-gray-100" - aria-label={`Increase quantity of ${item.getName()}`} - disabled={item.getQuantity().toValue() >= CartItem.MAX_QUANTITY} - data-umami-event="shopping-cart.increase-quantity" + aria-label={`Increase quantity of ${item.name()}`} + disabled={item.quantity().isAtLeast(CartItem.MAX_QUANTITY)} + data-umami-event={ANALYTICS_CONFIG.events.increaseQuantity} >