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