diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a11897e..184b2ac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,8 +38,44 @@ jobs:
- name: Lint markdown
run: pnpm lint:md
- - name: Test
- run: pnpm test
+ - name: Test with coverage
+ run: pnpm test:coverage
- name: Build
run: pnpm build
+
+ e2e:
+ name: End-to-end tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright's Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Build
+ run: pnpm build
+
+ - name: Run Playwright tests
+ run: pnpm test:e2e
+
+ - name: Upload Playwright report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 7
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c819b5a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,85 @@
+# Contributing
+
+## Getting started
+
+```sh
+pnpm install
+pnpm dev
+```
+
+Bun works too, if you'd rather not install pnpm: `bun install && bunx --bun run dev`.
+
+## TDD
+
+Domain code (`src/domain/**`) is written test-first: red, green, refactor. A behavior
+change starts with a failing spec next to the class it exercises (`__spec__/`), then the
+smallest change that passes it, then a cleanup pass with the tests as a safety net. Two
+concrete examples from this codebase's own history:
+
+- `LocalStorageWelcomeSurveyRepository.getSurvey()` had a cache bug that made it return
+ `null` forever. The regression spec (`should read a survey that was written to storage
+ by another instance`) fails against the old code and passes against the fix, so it
+ cannot silently come back.
+- `Product.getLoreLink`/`getVideoUrl` used to have three branches that all returned the
+ same expression. Giving each knowledge level distinct fixture data in the spec, instead
+ of one profile reused three times, is what made the dead branches visible.
+
+## The testing trophy
+
+Effort is weighted toward the middle, not the extremes:
+
+```mermaid
+flowchart TD
+ A["Static analysis: TypeScript, Biome"] --> B["Unit: src/domain/**"]
+ B --> C["Integration: src/ui/**, real components, mocked storage boundaries"]
+ C --> D["End-to-end: 2 Playwright smoke specs"]
+```
+
+- **Static**: `pnpm typecheck` and `pnpm check` catch a whole class of bugs before a test
+ even runs.
+- **Unit** (`vitest --project unit`, node environment): every domain class, one behavior
+ per test. This is most of the suite, domain logic has no framework to fight.
+- **Integration** (`vitest --project integration`, jsdom): renders real components with
+ `@testing-library/react` and exercises real user flows: add to cart, change quantity,
+ the welcome survey save/restore. Framework wiring (React, Radix) is real; only the
+ storage boundary gets mocked when a scenario the real data can't reach needs it (see
+ `ShoppingCart.freeShipping.spec.tsx`).
+- **End-to-end** (`pnpm test:e2e`, Playwright against a built `vite preview`): two smoke
+ tests, home to shop and add-to-cart to checkout. Enough to catch a broken build, not a
+ replacement for the layers below.
+
+## Commands
+
+| Command | What it does |
+| ----------------------- | ------------------------------------------------ |
+| `pnpm test` | Runs the unit and integration Vitest projects |
+| `pnpm test:watch` | Same, in watch mode |
+| `pnpm test:coverage` | Runs tests with coverage thresholds enforced |
+| `pnpm test:e2e` | Builds nothing itself, run `pnpm build` first, then the 2 Playwright specs |
+| `pnpm typecheck` | `tsc --noEmit` across the app, tooling, and tests |
+| `pnpm check` | Biome lint + format check |
+| `pnpm check:fix` | Biome lint + format, writing fixes |
+
+## Object Calisthenics
+
+The rules this repo tries to hold itself to, and where to see them:
+
+1. One level of indentation per method — early returns throughout `src/domain`
+2. Max ~50 lines per class — `Cart`, `CartItem`, `CartItems`
+3. Wrap primitives — `Money`, `Quantity`, `ProductId`, `ProductName`
+4. First-class collections — `CartItems` wraps the cart's `CartItem[]`
+5. One dot per line (Law of Demeter) — `Cart.increaseQuantity(itemId)` instead of pulling
+ a `Product` back out of a `CartItem` to hand it to `Cart`
+6. No getters/setters unless necessary — `CartItem.id()`/`name()`/`quantity()` are
+ necessary, `getProduct()` wasn't and is gone
+7. No else — grep the repo, there isn't one
+8. Descriptive names, single responsibility, one thing per method — the rest of it
+
+None of this is free: it costs more classes and more indirection than the equivalent
+procedural code. The trade is code whose pieces can be understood, tested, and changed
+one at a time.
+
+## Commits
+
+Conventional Commits, scope required: `type(scope): subject`. Enforced by commitlint via
+a pre-commit hook (Lefthook), not just convention.
diff --git a/e2e/add-to-cart.spec.ts b/e2e/add-to-cart.spec.ts
new file mode 100644
index 0000000..4797f7b
--- /dev/null
+++ b/e2e/add-to-cart.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "@playwright/test";
+
+test("visitor can add a product to the cart and reach checkout", async ({ page }) => {
+ await page.goto("shopping-cart/");
+
+ // First visit: the knowledge-level survey opens automatically.
+ await expect(page.getByText("Choose Your Knowledge Level")).toBeVisible();
+ await page.getByRole("combobox").selectOption("beginner");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect(page.getByText("Choose Your Knowledge Level")).not.toBeVisible();
+
+ await page.getByRole("button", { name: "Add to Cart" }).first().click();
+ await expect(page.getByText("Your inventory is empty")).not.toBeVisible();
+
+ await page.getByRole("button", { name: "Complete Purchase" }).click();
+
+ await expect(page.getByText("End of the Demo")).toBeVisible();
+});
diff --git a/e2e/home-to-shop.spec.ts b/e2e/home-to-shop.spec.ts
new file mode 100644
index 0000000..573c8c0
--- /dev/null
+++ b/e2e/home-to-shop.spec.ts
@@ -0,0 +1,19 @@
+import { expect, test } from "@playwright/test";
+
+test("visitor can get from the home page to the shop", async ({ page }) => {
+ await page.goto("./");
+
+ await expect(
+ page.getByRole("heading", { name: "Maintainable Frontend Architecture with React" }),
+ ).toBeVisible();
+
+ await page.getByRole("link", { name: /Try the Shopping Cart Experience/ }).click();
+ await expect(page).toHaveURL(/\/shopping-cart\/?$/);
+
+ // The knowledge-level survey opens automatically on a first visit and hides the rest
+ // of the page from assistive tech (and from role-based locators) while it is open.
+ await page.getByRole("combobox").selectOption("beginner");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await expect(page.getByRole("heading", { name: "Mystical Inventory" })).toBeVisible();
+});
diff --git a/package.json b/package.json
index 0ce05f1..cd6190f 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,8 @@
"check:fix": "biome check --write .",
"test": "vitest run",
"test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "test:e2e": "playwright test",
"prepare": "lefthook install",
"deploy": "pnpm build && npx gh-pages -d dist"
},
@@ -66,12 +68,15 @@
"@biomejs/biome": "2.5.11",
"@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2",
+ "@playwright/test": "1.62.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
+ "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^30.0.1",
"lefthook": "2.1.12",
"remark-cli": "12.0.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..572109e
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const PORT = 4173;
+const BASE_PATH = "/react-object-calisthenics/";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: "html",
+ use: {
+ baseURL: `http://localhost:${PORT}${BASE_PATH}`,
+ trace: "on-first-retry",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+ webServer: {
+ command: "pnpm run preview",
+ url: `http://localhost:${PORT}${BASE_PATH}`,
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73269aa..428d257 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -93,15 +93,21 @@ importers:
'@commitlint/config-conventional':
specifier: 21.2.2
version: 21.2.2
+ '@playwright/test':
+ specifier: 1.62.1
+ version: 1.62.1
'@testing-library/dom':
specifier: ^10.4.1
version: 10.4.1
'@testing-library/jest-dom':
specifier: ^7.0.1
- version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)))
+ version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)
'@testing-library/react':
specifier: ^16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@testing-library/user-event':
+ specifier: ^14.6.6
+ version: 14.6.6(@testing-library/dom@10.4.1)
'@types/react':
specifier: ^19.2.18
version: 19.2.18
@@ -111,6 +117,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
+ '@vitest/coverage-v8':
+ specifier: ^4.1.11
+ version: 4.1.11(vitest@4.1.11)
jsdom:
specifier: ^30.0.1
version: 30.0.1
@@ -137,7 +146,7 @@ importers:
version: 8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.11
- version: 4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
+ version: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
packages:
@@ -286,6 +295,10 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
'@biomejs/biome@2.5.11':
resolution: {integrity: sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==}
engines: {node: '>=14.21.3'}
@@ -530,6 +543,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.62.1':
+ resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@radix-ui/primitive@1.1.7':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
@@ -1180,6 +1198,12 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.6.6':
+ resolution: {integrity: sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1252,6 +1276,15 @@ packages:
oxc-transform-react:
optional: true
+ '@vitest/coverage-v8@4.1.11':
+ resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==}
+ peerDependencies:
+ '@vitest/browser': 4.1.11
+ vitest: 4.1.11
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
'@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
@@ -1338,6 +1371,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ ast-v8-to-istanbul@1.0.5:
+ resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==}
+
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
@@ -1635,6 +1671,11 @@ packages:
react-dom:
optional: true
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1677,6 +1718,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
hosted-git-info@7.0.2:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
@@ -1685,6 +1730,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
ignore@6.0.2:
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
engines: {node: '>= 4'}
@@ -1772,6 +1820,18 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -1783,6 +1843,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2048,6 +2111,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.5.4:
+ resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
markdown-extensions@2.0.0:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
@@ -2310,6 +2380,16 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -2609,6 +2689,10 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
supports-color@9.4.0:
resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==}
engines: {node: '>=12'}
@@ -3142,6 +3226,8 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@bcoe/v8-coverage@1.0.2': {}
+
'@biomejs/biome@2.5.11':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.5.11
@@ -3414,6 +3500,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.62.1':
+ dependencies:
+ playwright: 1.62.1
+
'@radix-ui/primitive@1.1.7': {}
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)':
@@ -3907,7 +3997,7 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
- '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)))':
+ '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)':
dependencies:
'@adobe/css-tools': 4.5.0
'@testing-library/dom': 10.4.1
@@ -3917,7 +4007,7 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
optionalDependencies:
- vitest: 4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
+ vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
'@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
@@ -3929,6 +4019,10 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.5(@types/react@19.2.18)
+ '@testing-library/user-event@14.6.6(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@types/aria-query@5.0.4': {}
'@types/chai@5.2.3':
@@ -3989,6 +4083,20 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)
+ '@vitest/coverage-v8@4.1.11(vitest@4.1.11)':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.11
+ ast-v8-to-istanbul: 1.0.5
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.4
+ obug: 2.1.4
+ std-env: 4.2.0
+ tinyrainbow: 3.1.1
+ vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
+
'@vitest/expect@4.1.11':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -4074,6 +4182,12 @@ snapshots:
assertion-error@2.0.1: {}
+ ast-v8-to-istanbul@1.0.5:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.7
@@ -4332,6 +4446,9 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -4366,6 +4483,8 @@ snapshots:
graceful-fs@4.2.11: {}
+ has-flag@4.0.0: {}
+
hosted-git-info@7.0.2:
dependencies:
lru-cache: 10.4.3
@@ -4376,6 +4495,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ html-escaper@2.0.2: {}
+
ignore@6.0.2: {}
immutable-instance@0.0.9(react@19.2.8):
@@ -4436,6 +4557,19 @@ snapshots:
isexe@3.1.5: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -4446,6 +4580,8 @@ snapshots:
jiti@2.7.0: {}
+ js-tokens@10.0.0: {}
+
js-tokens@4.0.0: {}
js-yaml@4.3.2:
@@ -4660,6 +4796,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
+ magicast@0.5.4:
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.5
+
markdown-extensions@2.0.0: {}
markdown-table@3.0.4: {}
@@ -5123,6 +5269,14 @@ snapshots:
picomatch@4.0.7: {}
+ playwright-core@1.62.1: {}
+
+ playwright@1.62.1:
+ dependencies:
+ playwright-core: 1.62.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
postcss@8.5.26:
@@ -5572,6 +5726,10 @@ snapshots:
dependencies:
min-indent: 1.0.1
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
supports-color@9.4.0: {}
symbol-tree@3.2.4: {}
@@ -5821,7 +5979,7 @@ snapshots:
jiti: 2.7.0
yaml: 2.9.0
- vitest@4.1.11(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)):
+ vitest@4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
@@ -5845,6 +6003,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
+ '@vitest/coverage-v8': 4.1.11(vitest@4.1.11)
jsdom: 30.0.1
transitivePeerDependencies:
- msw
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/domain/welcomeSurvey/infrastructure/__spec__/ZustandWelcomeSurvey.repository.spec.ts b/src/domain/welcomeSurvey/infrastructure/__spec__/ZustandWelcomeSurvey.repository.spec.ts
new file mode 100644
index 0000000..9a60a0f
--- /dev/null
+++ b/src/domain/welcomeSurvey/infrastructure/__spec__/ZustandWelcomeSurvey.repository.spec.ts
@@ -0,0 +1,35 @@
+/** @vitest-environment jsdom */
+import { describe, expect, it, vi } from "vitest";
+import { ZustandWelcomeSurveyRepository } from "../ZustandWelcomeSurvey.repository";
+
+describe("ZustandWelcomeSurveyRepository", () => {
+ it("should save and read back a survey", () => {
+ const repository = new ZustandWelcomeSurveyRepository();
+
+ repository.saveSurvey({ skill: "expert" });
+
+ expect(repository.getSurvey()).toEqual({ skill: "expert" });
+ });
+
+ it("should share state across instances, same as the other repositories", () => {
+ const writer = new ZustandWelcomeSurveyRepository();
+ writer.saveSurvey({ skill: "intermediate" });
+
+ const reader = new ZustandWelcomeSurveyRepository();
+
+ expect(reader.getSurvey()).toEqual({ skill: "intermediate" });
+ });
+
+ it("should notify subscribers when a survey is saved", () => {
+ const repository = new ZustandWelcomeSurveyRepository();
+ const listener = vi.fn();
+ const unsubscribe = repository.subscribe(listener);
+
+ repository.saveSurvey({ skill: "beginner" });
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ unsubscribe();
+ repository.saveSurvey({ skill: "expert" });
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/globals.d.ts b/src/globals.d.ts
index 54f14ff..69dd9fd 100644
--- a/src/globals.d.ts
+++ b/src/globals.d.ts
@@ -2,4 +2,13 @@ interface Umami {
track: (eventName: string, props?: Record