VendorMenu.cs
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SWB.Base;
using SWB.Player;

// The loadout board itself. Shows the local player's cash and two columns
// of stock (primary / secondary); clicking a line buys and equips it.
//
// Follows the same no-Razor PanelComponent pattern as swb_hud. Put this on
// the same GameObject as the Vendor and assign Vendor; it creates its own
// ScreenPanel, so no extra editor wiring.
public sealed class VendorMenu : PanelComponent
{
	[Property]
	public Vendor Vendor { get; set; }

	Label _cashLabel;
	Panel _primaryColumn;
	Panel _secondaryColumn;

	bool _wasOpen = false;

	protected override void OnStart()
	{
		Components.GetOrCreate<ScreenPanel>();

		Panel.StyleSheet.Load( "/VendorMenu.cs.scss" );

		var header = Panel.Add.Panel( "header" );
		header.Add.Label( "Loadout", "title" );
		_cashLabel = header.Add.Label( "", "cash" );

		// Holding Use again also closes it, but a visible way out matters
		// when the cursor is already up and the menu is covering the
		// screen - nothing on screen tells you the hold-to-close trick.
		var close = header.Add.Label( "✕", "close" );
		close.AddEventListener( "onmousedown", () => Vendor?.Close() );

		var columns = Panel.Add.Panel( "columns" );

		_primaryColumn = columns.Add.Panel( "column" );
		_primaryColumn.Add.Label( "Primary", "columnTitle" );

		_secondaryColumn = columns.Add.Panel( "column" );
		_secondaryColumn.Add.Label( "Secondary", "columnTitle" );
	}

	protected override void OnUpdate()
	{
		if ( Vendor is null )
			return;

		Panel.SetClass( "show", Vendor.IsOpen );

		// Rebuild only on open so prices/ownership reflect whatever the
		// player walked up with, without churning panels every frame.
		if ( Vendor.IsOpen && !_wasOpen )
			Rebuild();

		// Release the cursor so the menu can be clicked - see MenuInput for
		// why this works on mouse-look rather than on Mouse.Visible.
		if ( Vendor.IsOpen != _wasOpen )
			MenuInput.SetMenuOpen( Vendor.IsOpen );

		_wasOpen = Vendor.IsOpen;

		if ( Vendor.IsOpen )
			UpdateCash();
	}

	void UpdateCash()
	{
		var loadout = LocalLoadout();
		_cashLabel.Text = loadout is null ? "" : $"${loadout.Cash}";
	}

	void Rebuild()
	{
		ClearEntries( _primaryColumn );
		ClearEntries( _secondaryColumn );

		foreach ( var entry in Vendor.Stock )
		{
			if ( entry is null || string.IsNullOrWhiteSpace( entry.ClassName ) )
				continue;

			var column = entry.Slot == LoadoutSlot.Primary ? _primaryColumn : _secondaryColumn;
			AddEntry( column, entry );
		}
	}

	void AddEntry( Panel column, VendorEntry entry )
	{
		var weapon = WeaponRegistry.Instance?.Get( entry.ClassName );
		var displayName = weapon?.DisplayName ?? entry.ClassName;

		var row = column.Add.Panel( "entry" );

		if ( !string.IsNullOrWhiteSpace( weapon?.Icon ) )
			row.Add.Image( weapon.Icon, "icon" );

		row.Add.Label( displayName, "name" );
		row.Add.Label( $"${entry.Cost}", "cost" );

		row.AddEventListener( "onmousedown", () =>
		{
			var loadout = LocalLoadout();
			if ( loadout is null )
				return;

			if ( !loadout.TryBuy( entry.ClassName, entry.Cost, entry.Slot ) )
				return;

			Rebuild();
			UpdateCash();
		} );

		// Reflect current state: what you already have equipped, and what
		// you simply can't afford right now.
		var loadout = LocalLoadout();
		if ( loadout is not null )
		{
			var equipped = entry.Slot == LoadoutSlot.Primary ? loadout.Primary : loadout.Secondary;
			row.SetClass( "equipped", equipped == entry.ClassName );
			row.SetClass( "unaffordable", !loadout.CanAfford( entry.Cost ) );
		}
	}

	void ClearEntries( Panel column )
	{
		foreach ( var child in column.Children )
		{
			if ( child.HasClass( "entry" ) )
				child.Delete();
		}
	}

	PlayerLoadout LocalLoadout()
	{
		return PlayerBase.Local?.Components.Get<PlayerLoadout>();
	}
}