diff --git a/apps/web/src/app/features/inventory/inventory-page.component.html b/apps/web/src/app/features/inventory/inventory-page.component.html new file mode 100644 index 0000000..1d16a21 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.html @@ -0,0 +1,65 @@ + +
+ @if (inventoryStore.loading() && !inventoryStore.inventory()) { +

Inventar wird geladen…

+ } @else if (inventoryStore.inventory(); as inventory) { +
+ @for (entry of inventory.items; track entry.id) { + + } @empty { +

Noch keine Gegenstände gefunden.

+ } +
+ + + } + + @if (inventoryStore.error(); as error) { + + } +
diff --git a/apps/web/src/app/features/inventory/inventory-page.component.scss b/apps/web/src/app/features/inventory/inventory-page.component.scss new file mode 100644 index 0000000..bc83851 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.scss @@ -0,0 +1,133 @@ +// apps/web/src/app/features/inventory/inventory-page.component.scss +:host { + display: block; +} + +.inventory-page { + display: grid; + grid-template-columns: 1fr 20rem; + gap: var(--ar-space-5); + align-items: start; + padding: var(--ar-space-5); +} + +.inventory-page__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); + gap: var(--ar-space-3); +} + +.inventory-page__slot { + position: relative; + padding: var(--ar-space-2); + border: 1px solid transparent; + border-radius: var(--ar-radius-sm); + background: transparent; +} + +.inventory-page__slot--selected { + border-color: var(--ar-border-highlight); + background: rgb(155 122 66 / 0.12); +} + +.inventory-page__equipped-badge { + position: absolute; + inset-block-start: 0.1rem; + inset-inline-start: 50%; + translate: -50% 0; + padding: 0.05rem 0.4rem; + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + color: var(--ar-gold); + background: rgb(9 11 13 / 0.9); + font-size: 0.65rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.inventory-page__empty { + grid-column: 1 / -1; + padding: var(--ar-space-4); + color: var(--ar-text-muted); + font-style: italic; +} + +.inventory-page__side { + display: grid; + gap: var(--ar-space-4); +} + +.inventory-page__equipment { + padding: var(--ar-space-4); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel); +} + +.inventory-page__equipment h3 { + margin: 0 0 var(--ar-space-2); + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.95rem; + font-weight: 400; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.inventory-page__equipment-list { + display: grid; + gap: var(--ar-space-1); + margin: 0 0 var(--ar-space-3); + padding: 0; + list-style: none; +} + +.inventory-page__equipment-list li { + display: flex; + justify-content: space-between; + padding-block: var(--ar-space-1); + border-block-end: 1px solid rgb(85 74 57 / 0.4); + font-size: var(--ar-font-sm); +} + +.inventory-page__equipment-slot-label { + color: var(--ar-text-muted); +} + +.inventory-page__stats { + display: grid; + gap: var(--ar-space-1); + margin: 0; + padding-block-start: var(--ar-space-2); + border-block-start: 1px solid rgb(155 122 66 / 0.45); +} + +.inventory-page__stats div { + display: flex; + justify-content: space-between; +} + +.inventory-page__stats dt { + color: var(--ar-text-muted); +} + +.inventory-page__stats dd { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; +} + +.inventory-page__notice { + padding: var(--ar-space-4); + color: var(--ar-text-muted); + text-align: center; +} + +.inventory-page__notice--error { + color: var(--ar-danger); +} + +@media (width < 960px) { + .inventory-page { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/app/features/inventory/inventory-page.component.spec.ts b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts new file mode 100644 index 0000000..0ba100d --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.spec.ts @@ -0,0 +1,143 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import type { CharacterResponse, EquipmentResponse, InventoryResponse } from '../../core/api/game-api.models'; +import { WorldStore } from '../world/world.store'; +import { InventoryPageComponent } from './inventory-page.component'; +import { InventoryStore } from './inventory.store'; + +const inventory: InventoryResponse = { + items: [ + { + id: 'item-sword', + quantity: 1, + equipped: true, + item: { + key: 'worn-short-sword', + name: 'Abgenutztes Kurzschwert', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 8, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/worn-short-sword.png', + }, + }, + { + id: 'item-blade', + quantity: 1, + equipped: false, + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: 'COMMON', + equipmentSlot: 'WEAPON', + requiredLevel: 1, + weaponDamage: 11, + bonusAttack: 1, + bonusHp: 0, + bonusArmor: 0, + iconPath: '/images/items/bandit-blade.png', + }, + }, + ], +}; + +const equipment: EquipmentResponse = { + slots: { + WEAPON: { characterItemId: 'item-sword', item: { key: 'worn-short-sword', name: 'Abgenutztes Kurzschwert', rarity: 'COMMON', iconPath: '/images/items/worn-short-sword.png' } }, + HEAD: null, + CHEST: null, + HANDS: null, + LEGS: null, + FEET: null, + AMULET: null, + }, + stats: { maxHp: 100, attack: 6, weaponDamage: 8, armor: 0 }, +}; + +const character: CharacterResponse = { + id: 'character-1', + name: 'Aric Duskwalker', + level: 1, + experience: 0, + silver: 0, + currentHp: 100, + maxHp: 100, + attack: 6, + currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, +}; + +async function setup() { + const inventoryStore = { + inventory: signal(inventory), + equipment: signal(equipment), + selectedItemId: signal(null), + loading: signal(false), + equipping: signal(false), + error: signal(null), + load: vi.fn(() => Promise.resolve()), + selectItem: vi.fn(), + selectedItem: vi.fn(() => null), + equip: vi.fn(() => Promise.resolve()), + }; + const worldStore = { character: signal(character) }; + + await TestBed.configureTestingModule({ + imports: [InventoryPageComponent], + providers: [ + { provide: InventoryStore, useValue: inventoryStore }, + { provide: WorldStore, useValue: worldStore }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(InventoryPageComponent); + fixture.detectChanges(); + return { fixture, inventoryStore }; +} + +describe('InventoryPageComponent', () => { + it('loads the inventory on init', async () => { + const { inventoryStore } = await setup(); + expect(inventoryStore.load).toHaveBeenCalledOnce(); + }); + + it('renders one tile per owned item', async () => { + const { fixture } = await setup(); + const tiles = (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot'); + expect(tiles.length).toBe(2); + }); + + it('marks the equipped item with a badge', async () => { + const { fixture } = await setup(); + expect((fixture.nativeElement as HTMLElement).querySelector('[data-slot-equipped]')).not.toBeNull(); + }); + + it('selects an item when its tile is clicked', async () => { + const { fixture, inventoryStore } = await setup(); + (fixture.nativeElement as HTMLElement).querySelectorAll('.inventory-page__slot')[1].click(); + + expect(inventoryStore.selectItem).toHaveBeenCalledWith('item-blade'); + }); + + it('shows the equipment overview with all seven slots and empty ones as Leer', async () => { + const { fixture } = await setup(); + const text = (fixture.nativeElement as HTMLElement).querySelector('.inventory-page__equipment-list')?.textContent ?? ''; + + expect(text).toContain('Waffe'); + expect(text).toContain('Abgenutztes Kurzschwert'); + expect(text).toContain('Kopf'); + expect(text).toContain('Leer'); + }); + + it('shows the effective stats summary from the equipment response', async () => { + const { fixture } = await setup(); + const text = (fixture.nativeElement as HTMLElement).querySelector('[data-inventory-stats]')?.textContent ?? ''; + + expect(text).toContain('100'); + expect(text).toContain('6'); + expect(text).toContain('8'); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory-page.component.ts b/apps/web/src/app/features/inventory/inventory-page.component.ts new file mode 100644 index 0000000..e0b3eda --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-page.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit, computed, inject } from '@angular/core'; +import type { EquipmentSlot } from '../../core/api/game-api.models'; +import { ItemCardComponent } from '../../shared/item-card/item-card.component'; +import { WorldStore } from '../world/world.store'; +import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; +import { InventoryStore } from './inventory.store'; + +const SLOT_ORDER: readonly EquipmentSlot[] = [ + 'WEAPON', + 'HEAD', + 'CHEST', + 'HANDS', + 'LEGS', + 'FEET', + 'AMULET', +]; + +const SLOT_LABELS: Readonly> = { + WEAPON: 'Waffe', + HEAD: 'Kopf', + CHEST: 'Brust', + HANDS: 'Handschuhe', + LEGS: 'Beine', + FEET: 'Stiefel', + AMULET: 'Amulett', +}; + +@Component({ + selector: 'app-inventory-page', + imports: [ItemCardComponent, InventoryDetailPanelComponent], + templateUrl: './inventory-page.component.html', + styleUrl: './inventory-page.component.scss', +}) +export class InventoryPageComponent implements OnInit { + protected readonly inventoryStore = inject(InventoryStore); + private readonly worldStore = inject(WorldStore); + protected readonly slotOrder = SLOT_ORDER; + protected readonly slotLabels = SLOT_LABELS; + + protected readonly characterLevel = computed(() => this.worldStore.character()?.level ?? 1); + + protected readonly equippedItemInSelectedSlot = computed(() => { + const selected = this.inventoryStore.selectedItem(); + if (!selected?.item.equipmentSlot) { + return null; + } + return ( + this.inventoryStore.inventory()?.items.find( + (item) => item.equipped && item.item.equipmentSlot === selected.item.equipmentSlot, + ) ?? null + ); + }); + + ngOnInit(): void { + void this.inventoryStore.load(); + } + + protected selectItem(itemId: string): void { + this.inventoryStore.selectItem(itemId); + } + + protected async equipSelected(characterItemId: string): Promise { + await this.inventoryStore.equip(characterItemId); + } + + protected retry(): void { + void this.inventoryStore.load(); + } +}