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/ui/map-viewer/
arma/mod/.hemttout/ arma/mod/.hemttout/
arma/server/surrealdb/forge.db/ arma/server/surrealdb/forge.db/
arma/temp/
promo/ 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} .card-kicker,
${scopeSelector} .product-code, ${scopeSelector} .product-code,
${scopeSelector} .product-tag,
${scopeSelector} .empty-state-kicker { ${scopeSelector} .empty-state-kicker {
font-size: 0.72rem; font-size: 0.72rem;
letter-spacing: 0.14em; letter-spacing: 0.14em;
@ -156,6 +157,21 @@ ${scopeSelector} .product-meta {
gap: 0.2rem; 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 { ${scopeSelector} .product-name {
font-size: 0.96rem; font-size: 0.96rem;
font-weight: 700; font-weight: 700;
@ -370,6 +386,10 @@ ${scopeSelector} .product-copy {
item.description, item.description,
item.className || item.code, item.className || item.code,
); );
const tags = [
item.sideLabel ? item.sideLabel : "",
item.factionName ? item.factionName : "",
].filter(Boolean);
return h( return h(
"article", "article",
@ -399,6 +419,15 @@ ${scopeSelector} .product-copy {
{ className: "product-code" }, { className: "product-code" },
item.type || item.code || item.className, 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("strong", { className: "product-name" }, item.name),
), ),
h("p", { className: "product-copy" }, description), h("p", { className: "product-copy" }, description),

View File

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

View File

@ -17,6 +17,10 @@
type: String(item?.type || ""), type: String(item?.type || ""),
category: String(item?.category || ""), category: String(item?.category || ""),
entryKind: String(item?.entryKind || "item"), 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)), 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 `modMode` is applied first. After that, any non-empty
listed for the requested category. `denylist` removes listed classnames from the `Categories.<category>[]` array acts as an explicit allowlist for only that
generated category. Overrides are applied server-side, so checkout validation category, regardless of `mode`. Categories with empty arrays follow `mode`:
uses the same prices and descriptions the UI displays. `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 `modMode` applies before category filtering. `dynamic` means no mod-source
filtering. `allowlist` only keeps generated entries that match one of the 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 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. treated as available and only the source/prefix/contains/DLC checks are used.
`units[]` follows the same `dynamic`, `allowlist`, and `denylist` behavior as Unit catalog responses are additionally filtered to the requesting player's
item and vehicle categories. Unit purchases are immediate spawn grants, not side. Unit entries include side and faction metadata for UI display, and
durable virtual garage unlocks. 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 The filter is currently global for the mission. Revisit per-store profile
support if individual vendors need different inventories. 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." 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); [CRPC(store,responseCategory), [_result], _player] call CFUNC(targetEvent);
}] call CFUNC(addEventHandler); }] call CFUNC(addEventHandler);

View File

@ -294,21 +294,74 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _classNames = _self call ["getMissionStoreCategoryList", [_category]]; private _classNames = _self call ["getMissionStoreCategoryList", [_category]];
private _filteredItems = _self call ["applyMissionStoreModFilter", [_items]]; private _filteredItems = _self call ["applyMissionStoreModFilter", [_items]];
switch (_mode) do { if (_classNames isNotEqualTo []) then {
case "allowlist": {
_filteredItems = _filteredItems select { _filteredItems = _filteredItems select {
(toLowerANSI (_x getOrDefault ["className", ""])) in _classNames (toLowerANSI (_x getOrDefault ["className", ""])) in _classNames
}; };
}; } else {
switch (_mode) do {
case "allowlist": { _filteredItems = []; };
case "denylist": { case "denylist": {
_filteredItems = _filteredItems select { _filteredItems = _filteredItems select {
!((toLowerANSI (_x getOrDefault ["className", ""])) in _classNames) !((toLowerANSI (_x getOrDefault ["className", ""])) in _classNames)
}; };
}; };
}; };
};
_filteredItems apply { _self call ["applyMissionStoreOverrides", [_x]] } _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 { ["formatCurrency", compileFinal {
params [["_amount", 0, [0]]]; params [["_amount", 0, [0]]];
@ -384,8 +437,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
}; };
private _priceValue = _self call ["calculateCatalogPriceValue", [_cfg, _isVehicle]]; private _priceValue = _self call ["calculateCatalogPriceValue", [_cfg, _isVehicle]];
private _item = createHashMapFromArray [
createHashMapFromArray [
["className", _className], ["className", _className],
["code", _className], ["code", _className],
["name", _displayName], ["name", _displayName],
@ -398,7 +450,26 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
["sourceMod", _sourceMod], ["sourceMod", _sourceMod],
["sourceDLC", _sourceDLC], ["sourceDLC", _sourceDLC],
["sourceAuthor", _sourceAuthor] ["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 { ["appendCfgWeaponsByItemInfoType", compileFinal {
params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_itemKind", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]]; params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_itemKind", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
@ -686,7 +757,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_items _items
}], }],
["buildCategoryResponse", compileFinal { ["buildCategoryResponse", compileFinal {
params [["_category", "", [""]]]; params [["_category", "", [""]], ["_player", objNull, [objNull]]];
private _categoryKey = _self call ["normalizeCategoryKey", [_category]]; private _categoryKey = _self call ["normalizeCategoryKey", [_category]];
private _response = createHashMapFromArray [["success", false], ["category", _categoryKey], ["items", []], ["message", "No store category was provided."]]; 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 ["success", true];
_response set ["message", ""]; _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 _response
}], }],
["resolveCheckoutCategories", compileFinal { ["resolveCheckoutCategories", compileFinal {
@ -734,7 +808,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_resolved _resolved
}], }],
["buildCheckoutRequest", compileFinal { ["buildCheckoutRequest", compileFinal {
params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]]; params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]], ["_player", objNull, [objNull]]];
private _result = createHashMapFromArray [ private _result = createHashMapFromArray [
["success", false], ["success", false],
@ -810,6 +884,12 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
if (_catalogEntry isEqualTo createHashMap) then { if (_catalogEntry isEqualTo createHashMap) then {
_message = format ["Unsupported store unit: %1", _className]; _message = format ["Unsupported store unit: %1", _className];
} else { } 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]; private _priceValue = _catalogEntry getOrDefault ["priceValue", 0];
_total = _total + _priceValue; _total = _total + _priceValue;
_resolvedUnits pushBack (createHashMapFromArray [ _resolvedUnits pushBack (createHashMapFromArray [

View File

@ -530,7 +530,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_result _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]; private _totalPrice = _checkoutRequest getOrDefault ["total", 0];
_result set ["paymentMethod", _paymentMethod]; _result set ["paymentMethod", _paymentMethod];

View File

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