feat(store): enhance product card with tags and metadata

- Added support for displaying product tags in the UI, including side and faction labels.
- Updated CSS to style product tags for better visibility.
- Modified StoreView and store registry to include side and faction information for items.
- Enhanced server-side catalog service to filter items based on the player's side.
- Updated documentation to reflect changes in store behavior and metadata handling.
This commit is contained in:
Jacob Schmidt 2026-06-09 17:25:28 -05:00
parent c676a9084e
commit 19eae5acfa
10 changed files with 157 additions and 31 deletions

1
.gitignore vendored
View File

@ -37,4 +37,5 @@ Thumbs.db
arma/ui/map-viewer/
arma/mod/.hemttout/
arma/server/surrealdb/forge.db/
arma/temp/
promo/

File diff suppressed because one or more lines are too long

View File

@ -94,6 +94,7 @@ ${scopeSelector} .product-card:hover {
${scopeSelector} .card-kicker,
${scopeSelector} .product-code,
${scopeSelector} .product-tag,
${scopeSelector} .empty-state-kicker {
font-size: 0.72rem;
letter-spacing: 0.14em;
@ -156,6 +157,21 @@ ${scopeSelector} .product-meta {
gap: 0.2rem;
}
${scopeSelector} .product-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
${scopeSelector} .product-tag {
width: fit-content;
max-width: 100%;
padding: 0.18rem 0.42rem;
border-radius: 0.5rem;
border: 1px solid var(--store-accent-line);
background: rgb(255 255 255 / 0.42);
}
${scopeSelector} .product-name {
font-size: 0.96rem;
font-weight: 700;
@ -370,6 +386,10 @@ ${scopeSelector} .product-copy {
item.description,
item.className || item.code,
);
const tags = [
item.sideLabel ? item.sideLabel : "",
item.factionName ? item.factionName : "",
].filter(Boolean);
return h(
"article",
@ -399,6 +419,15 @@ ${scopeSelector} .product-copy {
{ className: "product-code" },
item.type || item.code || item.className,
),
tags.length > 0
? h(
"div",
{ className: "product-tags" },
tags.map((tag) =>
h("span", { className: "product-tag" }, tag),
),
)
: null,
h("strong", { className: "product-name" }, item.name),
),
h("p", { className: "product-copy" }, description),

View File

@ -195,6 +195,8 @@
item.description,
item.price,
item.type,
item.sideLabel,
item.factionName,
]),
);
}

View File

@ -17,6 +17,10 @@
type: String(item?.type || ""),
category: String(item?.category || ""),
entryKind: String(item?.entryKind || "item"),
side: String(item?.side || ""),
sideLabel: String(item?.sideLabel || ""),
faction: String(item?.faction || ""),
factionName: String(item?.factionName || ""),
quantity: Math.max(0, Number(item?.quantity || 0)),
};
}

View File

@ -71,10 +71,12 @@ class CfgStore {
};
```
`dynamic` keeps the full generated catalog. `allowlist` only shows classnames
listed for the requested category. `denylist` removes listed classnames from the
generated category. Overrides are applied server-side, so checkout validation
uses the same prices and descriptions the UI displays.
`modMode` is applied first. After that, any non-empty
`Categories.<category>[]` array acts as an explicit allowlist for only that
category, regardless of `mode`. Categories with empty arrays follow `mode`:
`dynamic` keeps generated entries and `allowlist` hides the category. Overrides
are applied server-side, so checkout validation uses the same prices and
descriptions the UI displays.
`modMode` applies before category filtering. `dynamic` means no mod-source
filtering. `allowlist` only keeps generated entries that match one of the
@ -86,9 +88,10 @@ metadata tokens that can appear anywhere, and `dlcs[]` for DLC/source/author
labels used by Creator DLC content. If a mod source defines no patches, it is
treated as available and only the source/prefix/contains/DLC checks are used.
`units[]` follows the same `dynamic`, `allowlist`, and `denylist` behavior as
item and vehicle categories. Unit purchases are immediate spawn grants, not
durable virtual garage unlocks.
Unit catalog responses are additionally filtered to the requesting player's
side. Unit entries include side and faction metadata for UI display, and
checkout validation rejects unit classnames from another side. Unit purchases
are immediate spawn grants, not durable virtual garage unlocks.
The filter is currently global for the mission. Revisit per-store profile
support if individual vendors need different inventories.

View File

@ -20,7 +20,7 @@ PREP_RECOMPILE_END;
diag_log "[FORGE:Server:Store] Store catalog service is unavailable."
};
private _result = GVAR(StoreCatalogService) call ["buildCategoryResponse", [_category]];
private _result = GVAR(StoreCatalogService) call ["buildCategoryResponse", [_category, _player]];
[CRPC(store,responseCategory), [_result], _player] call CFUNC(targetEvent);
}] call CFUNC(addEventHandler);

View File

@ -294,21 +294,74 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _classNames = _self call ["getMissionStoreCategoryList", [_category]];
private _filteredItems = _self call ["applyMissionStoreModFilter", [_items]];
switch (_mode) do {
case "allowlist": {
_filteredItems = _filteredItems select {
(toLowerANSI (_x getOrDefault ["className", ""])) in _classNames
};
if (_classNames isNotEqualTo []) then {
_filteredItems = _filteredItems select {
(toLowerANSI (_x getOrDefault ["className", ""])) in _classNames
};
case "denylist": {
_filteredItems = _filteredItems select {
!((toLowerANSI (_x getOrDefault ["className", ""])) in _classNames)
} else {
switch (_mode) do {
case "allowlist": { _filteredItems = []; };
case "denylist": {
_filteredItems = _filteredItems select {
!((toLowerANSI (_x getOrDefault ["className", ""])) in _classNames)
};
};
};
};
_filteredItems apply { _self call ["applyMissionStoreOverrides", [_x]] }
}],
["resolveSideLabel", compileFinal {
params [["_sideValue", -1, [0]]];
switch _sideValue do {
case 0: { "OPFOR" };
case 1: { "BLUFOR" };
case 2: { "Independent" };
case 3: { "Civilian" };
default { "Unknown" };
}
}],
["resolveSideKey", compileFinal {
params [["_sideValue", -1, [0]]];
switch _sideValue do {
case 0: { "east" };
case 1: { "west" };
case 2: { "resistance" };
case 3: { "civilian" };
default { "" };
}
}],
["resolvePlayerSideKey", compileFinal {
params [["_player", objNull, [objNull]]];
if (isNull _player) exitWith { "" };
switch (side group _player) do {
case west: { "west" };
case east: { "east" };
case independent: { "resistance" };
case civilian: { "civilian" };
default { "" };
}
}],
["doesItemMatchPlayerSide", compileFinal {
params [["_item", createHashMap, [createHashMap]], ["_player", objNull, [objNull]]];
if (_item isEqualTo createHashMap) exitWith { false };
private _playerSideKey = _self call ["resolvePlayerSideKey", [_player]];
if (_playerSideKey isEqualTo "") exitWith { true };
private _itemSideKey = _item getOrDefault ["side", ""];
_itemSideKey isEqualTo "" || { _itemSideKey isEqualTo _playerSideKey }
}],
["applyPlayerSideFilter", compileFinal {
params [["_category", "", [""]], ["_items", [], [[]]], ["_player", objNull, [objNull]]];
if !(_self call ["isUnitCategory", [_category]]) exitWith { +_items };
_items select { _self call ["doesItemMatchPlayerSide", [_x, _player]] }
}],
["formatCurrency", compileFinal {
params [["_amount", 0, [0]]];
@ -384,8 +437,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
};
private _priceValue = _self call ["calculateCatalogPriceValue", [_cfg, _isVehicle]];
createHashMapFromArray [
private _item = createHashMapFromArray [
["className", _className],
["code", _className],
["name", _displayName],
@ -398,7 +450,26 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
["sourceMod", _sourceMod],
["sourceDLC", _sourceDLC],
["sourceAuthor", _sourceAuthor]
]
];
if (isNumber (_cfg >> "side")) then {
private _sideValue = getNumber (_cfg >> "side");
_item set ["sideValue", _sideValue];
_item set ["side", _self call ["resolveSideKey", [_sideValue]]];
_item set ["sideLabel", _self call ["resolveSideLabel", [_sideValue]]];
};
if (isText (_cfg >> "faction")) then {
private _faction = getText (_cfg >> "faction");
if (_faction isNotEqualTo "") then {
private _factionName = getText (configFile >> "CfgFactionClasses" >> _faction >> "displayName");
if (_factionName isEqualTo "") then { _factionName = _faction; };
_item set ["faction", _faction];
_item set ["factionName", _factionName];
};
};
_item
}],
["appendCfgWeaponsByItemInfoType", compileFinal {
params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_itemKind", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
@ -686,7 +757,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_items
}],
["buildCategoryResponse", compileFinal {
params [["_category", "", [""]]];
params [["_category", "", [""]], ["_player", objNull, [objNull]]];
private _categoryKey = _self call ["normalizeCategoryKey", [_category]];
private _response = createHashMapFromArray [["success", false], ["category", _categoryKey], ["items", []], ["message", "No store category was provided."]];
@ -699,7 +770,10 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_response set ["success", true];
_response set ["message", ""];
_response set ["items", _self call ["buildCategoryItems", [_categoryKey]]];
private _items = _self call ["buildCategoryItems", [_categoryKey]];
_items = _self call ["applyPlayerSideFilter", [_categoryKey, _items, _player]];
_response set ["items", _items];
_response
}],
["resolveCheckoutCategories", compileFinal {
@ -734,7 +808,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_resolved
}],
["buildCheckoutRequest", compileFinal {
params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]];
params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]], ["_player", objNull, [objNull]]];
private _result = createHashMapFromArray [
["success", false],
@ -810,6 +884,12 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
if (_catalogEntry isEqualTo createHashMap) then {
_message = format ["Unsupported store unit: %1", _className];
} else {
if !(_self call ["doesItemMatchPlayerSide", [_catalogEntry, _player]]) then {
_message = format ["Store unit is not available for your side: %1", _className];
};
};
if (_message isEqualTo "") then {
private _priceValue = _catalogEntry getOrDefault ["priceValue", 0];
_total = _total + _priceValue;
_resolvedUnits pushBack (createHashMapFromArray [

View File

@ -530,7 +530,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_result
};
private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles, _units]];
private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles, _units, _player]];
private _totalPrice = _checkoutRequest getOrDefault ["total", 0];
_result set ["paymentMethod", _paymentMethod];

View File

@ -71,10 +71,17 @@ class CfgStore {
};
```
`dynamic` keeps the full generated catalog. `allowlist` only shows classnames
listed for each category. `denylist` removes listed classnames. Overrides are
server-side and are used by both the UI payload and checkout validation.
`units[]` uses the same filter behavior as every other category.
`modMode` is applied first. After that, any non-empty
`Categories.<category>[]` array acts as an explicit allowlist for only that
category, regardless of `mode`. Categories with empty arrays follow `mode`:
`dynamic` keeps the generated entries and `allowlist` hides the category.
Overrides are server-side and are used by both the UI payload and checkout
validation.
Unit catalog responses are additionally filtered to the requesting player's
side. Unit entries include side and faction metadata so the UI can show the
available faction before purchase, and checkout validation rejects unit
classnames from another side.
`modMode` applies before category filtering. `dynamic` means no mod-source
filtering. `allowlist` only keeps generated entries that match one of the
@ -93,9 +100,9 @@ modMode = "allowlist";
mods[] = {"rhs"};
```
The matching `class rhs` must exist under `ModSources`. Category `mode` is still
applied afterward, so leave `mode = "dynamic"` if the mod filter should be the
only inventory filter.
The matching `class rhs` must exist under `ModSources`. Category arrays are
still applied afterward, so leave arrays empty and `mode = "dynamic"` if the mod
filter should be the only inventory filter.
For Creator DLCs such as RF or WS, prefer both prefixes and DLC labels: