diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.html b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html new file mode 100644 index 0000000..e981664 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.html @@ -0,0 +1,60 @@ +@if (item(); as item) { +
+
+ +
+

{{ item.item.name }}

+

{{ rarityLabel() }}

+ @if (slotLabel(); as slot) { +

{{ slot }} · Stufe {{ item.item.requiredLevel }}

+ } +
+
+ + @if (statRows().length) { +
+ @for (row of statRows(); track row.label) { +
+
{{ row.label }}
+
+ {{ row.value }} + @if (row.diff !== null && row.diff !== 0) { + + ({{ row.diff > 0 ? '+' : '' }}{{ row.diff }}) + + } +
+
+ } +
+ } + +
+ @if (item.equipped) { + Ausgerüstet + } @else if (!isEquippable()) { + Nicht ausrüstbar + } @else if (!meetsLevelRequirement()) { + + } @else { + + } +
+
+} @else { +

Wähle einen Gegenstand aus deinem Inventar.

+} diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss new file mode 100644 index 0000000..49fb865 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.scss @@ -0,0 +1,124 @@ +:host { + display: block; +} + +.inventory-detail { + padding: var(--ar-space-4); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + background: + linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96); + box-shadow: var(--ar-shadow-raised); +} + +.inventory-detail__header { + display: flex; + gap: var(--ar-space-3); + align-items: center; + margin-block-end: var(--ar-space-3); +} + +.inventory-detail__icon { + inline-size: 4rem; + block-size: 4rem; + padding: var(--ar-space-1); + border: 1px solid var(--ar-border); + background: linear-gradient(180deg, #1b1f22, #0d1012); + object-fit: contain; +} + +.inventory-detail__name { + margin: 0; + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.15rem; + font-weight: 400; +} + +.inventory-detail__rarity { + margin: 0.15rem 0 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inventory-detail__meta { + margin: 0.25rem 0 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); +} + +.inventory-detail__stats { + display: grid; + gap: var(--ar-space-2); + margin: 0 0 var(--ar-space-3); + padding-block: var(--ar-space-2); + border-block: 1px solid rgb(155 122 66 / 0.45); +} + +.inventory-detail__stat { + display: flex; + justify-content: space-between; +} + +.inventory-detail__stat dt { + color: var(--ar-text-muted); +} + +.inventory-detail__stat dd { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; +} + +.inventory-detail__diff--positive { + color: var(--ar-success); +} + +.inventory-detail__diff--negative { + color: var(--ar-danger); +} + +.inventory-detail__actions { + display: flex; + justify-content: center; +} + +.inventory-detail__equipped { + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; +} + +.inventory-detail__note { + color: var(--ar-text-muted); + font-style: italic; +} + +.inventory-detail__equip { + inline-size: 100%; + padding: var(--ar-space-2) var(--ar-space-4); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: linear-gradient(180deg, #263b4b, #17232d); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1rem; +} + +.inventory-detail__equip:hover:not(:disabled) { + border-color: #d6b26b; + background: linear-gradient(180deg, #315067, #1a2c3a); +} + +.inventory-detail__equip:disabled { + border-color: var(--ar-border); + color: var(--ar-text-muted); + background: #1a1c1d; +} + +.inventory-detail__empty { + padding: var(--ar-space-4); + color: var(--ar-text-muted); + font-style: italic; + text-align: center; +} diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts new file mode 100644 index 0000000..109e9e6 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.spec.ts @@ -0,0 +1,106 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import type { InventoryItem } from '../../core/api/game-api.models'; +import { InventoryDetailPanelComponent } from './inventory-detail-panel.component'; + +const wornSword: InventoryItem = { + 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', + }, +}; + +const banditBlade: InventoryItem = { + 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', + }, +}; + +async function setup(overrides: { + item?: InventoryItem | null; + equippedItemInSlot?: InventoryItem | null; + characterLevel?: number; + busy?: boolean; +}) { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ imports: [InventoryDetailPanelComponent] }).compileComponents(); + const fixture = TestBed.createComponent(InventoryDetailPanelComponent); + fixture.componentRef.setInput('item', overrides.item ?? null); + fixture.componentRef.setInput('equippedItemInSlot', overrides.equippedItemInSlot ?? null); + fixture.componentRef.setInput('characterLevel', overrides.characterLevel ?? 1); + fixture.componentRef.setInput('busy', overrides.busy ?? false); + fixture.detectChanges(); + return fixture; +} + +describe('InventoryDetailPanelComponent', () => { + it('shows a placeholder when nothing is selected', async () => { + const fixture = await setup({ item: null }); + expect((fixture.nativeElement as HTMLElement).querySelector('[data-detail-empty]')).not.toBeNull(); + }); + + it('shows Ausgerüstet for the currently equipped item, with no equip button', async () => { + const fixture = await setup({ item: wornSword }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-detail-equipped]')?.textContent).toContain('Ausgerüstet'); + expect(element.querySelector('[data-detail-equip]')).toBeNull(); + }); + + it('shows the stat comparison against the equipped item in the same slot', async () => { + const fixture = await setup({ item: banditBlade, equippedItemInSlot: wornSword }); + const text = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-stats]')?.textContent ?? ''; + + expect(text).toContain('11'); + expect(text).toContain('+3'); + expect(text).toContain('+1'); + }); + + it('shows a disabled Benötigt Stufe X button when the level requirement is not met', async () => { + const fixture = await setup({ item: { ...banditBlade, item: { ...banditBlade.item, requiredLevel: 5 } }, characterLevel: 1 }); + const button = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-equip]'); + + expect(button?.textContent).toContain('Benötigt Stufe 5'); + expect(button?.disabled).toBe(true); + }); + + it('emits equip with the characterItemId when Ausrüsten is clicked', async () => { + const fixture = await setup({ item: banditBlade }); + const emitted: string[] = []; + fixture.componentInstance.equip.subscribe((id: string) => emitted.push(id)); + + (fixture.nativeElement as HTMLElement).querySelector('[data-detail-equip]')?.click(); + + expect(emitted).toEqual(['item-blade']); + }); + + it('disables the equip button while busy', async () => { + const fixture = await setup({ item: banditBlade, busy: true }); + const button = (fixture.nativeElement as HTMLElement).querySelector('[data-detail-equip]'); + + expect(button?.disabled).toBe(true); + }); +}); diff --git a/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts new file mode 100644 index 0000000..291f1e0 --- /dev/null +++ b/apps/web/src/app/features/inventory/inventory-detail-panel.component.ts @@ -0,0 +1,80 @@ +import { Component, computed, input, output } from '@angular/core'; +import type { EquipmentSlot, InventoryItem } from '../../core/api/game-api.models'; +import { RARITY_LABELS } from '../../shared/item-card/item-card.component'; + +const SLOT_LABELS: Readonly> = { + WEAPON: 'Waffe', + HEAD: 'Kopf', + CHEST: 'Brust', + HANDS: 'Handschuhe', + LEGS: 'Beine', + FEET: 'Stiefel', + AMULET: 'Amulett', +}; + +interface StatRow { + label: string; + value: number; + diff: number | null; +} + +type StatKey = 'weaponDamage' | 'bonusAttack' | 'bonusHp' | 'bonusArmor'; +const STAT_LABELS: ReadonlyArray<{ label: string; key: StatKey }> = [ + { label: 'Waffenschaden', key: 'weaponDamage' }, + { label: 'Angriff', key: 'bonusAttack' }, + { label: 'Leben', key: 'bonusHp' }, + { label: 'Rüstung', key: 'bonusArmor' }, +]; + +/** Selected-item details and equip comparison (spec §32–37). */ +@Component({ + selector: 'app-inventory-detail-panel', + templateUrl: './inventory-detail-panel.component.html', + styleUrl: './inventory-detail-panel.component.scss', +}) +export class InventoryDetailPanelComponent { + readonly item = input(null); + readonly equippedItemInSlot = input(null); + readonly characterLevel = input(1); + readonly busy = input(false); + readonly equip = output(); + + protected readonly rarityLabel = computed(() => { + const item = this.item(); + return item ? RARITY_LABELS[item.item.rarity] : ''; + }); + + protected readonly slotLabel = computed(() => { + const slot = this.item()?.item.equipmentSlot; + return slot ? SLOT_LABELS[slot] : null; + }); + + protected readonly statRows = computed(() => { + const item = this.item(); + if (!item) { + return []; + } + const compareTo = this.equippedItemInSlot(); + const comparable = compareTo && compareTo.id !== item.id ? compareTo.item : null; + + return STAT_LABELS.map(({ label, key }) => ({ + label, + value: item.item[key], + diff: comparable ? item.item[key] - comparable[key] : null, + })).filter((row) => row.value > 0 || (row.diff ?? 0) !== 0); + }); + + protected readonly isEquippable = computed(() => !!this.item()?.item.equipmentSlot); + + protected readonly meetsLevelRequirement = computed(() => { + const item = this.item(); + return item ? item.item.requiredLevel <= this.characterLevel() : true; + }); + + protected onEquip(): void { + const item = this.item(); + if (item) { + this.equip.emit(item.id); + } + } +}