Add live category hydration and paged store catalog UI
- route new `store::category::request` bridge events from UI to SQF and hydrate category items from game configs - cache and format generated catalog entries (types, descriptions, prices, images) in the store bridge/class - update storefront UI with category loading states, image media support, and next/previous catalog pagination
This commit is contained in:
parent
d178e39164
commit
09ab290b5a
@ -4,7 +4,7 @@
|
||||
* File: fnc_handleUIEvents.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-01-28
|
||||
* Last Update: 2026-02-06
|
||||
* Last Update: 2026-03-11
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
@ -32,12 +32,9 @@ diag_log format ["[FORGE:Client:Store] Handling UI event: %1 with data: %2", _ev
|
||||
|
||||
switch (_event) do {
|
||||
case "store::close": { closeDialog 1; };
|
||||
case "store::ready": {
|
||||
GVAR(StoreUIBridge) call ["handleReady", [_control]];
|
||||
};
|
||||
case "store::checkout::request": {
|
||||
GVAR(StoreUIBridge) call ["handleCheckoutRequest", [_data]];
|
||||
};
|
||||
case "store::ready": { GVAR(StoreUIBridge) call ["handleReady", [_control]]; };
|
||||
case "store::category::request": { GVAR(StoreUIBridge) call ["handleCategoryRequest", [_data]]; };
|
||||
case "store::checkout::request": { GVAR(StoreUIBridge) call ["handleCheckoutRequest", [_data]]; };
|
||||
default { hint format ["Unhandled UI event: %1", _event]; };
|
||||
};
|
||||
|
||||
|
||||
@ -4,12 +4,11 @@
|
||||
* File: fnc_initStoreClass.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-01-28
|
||||
* Last Update: 2026-02-06
|
||||
* Last Update: 2026-03-11
|
||||
* Public: Yes
|
||||
*
|
||||
* Description:
|
||||
* Initializes the store class for managing store data.
|
||||
* Provides methods for loading and applying store data.
|
||||
*
|
||||
* Arguments:
|
||||
* None
|
||||
@ -22,9 +21,9 @@
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(StoreClass) = createHashMapObject [[
|
||||
["#type", "IStoreClass"],
|
||||
["#create", {
|
||||
GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "StoreBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["uid", getPlayerUID player];
|
||||
_self set ["store", createHashMap];
|
||||
_self set ["workspace", createHashMapFromArray [
|
||||
@ -37,10 +36,10 @@ GVAR(StoreClass) = createHashMapObject [[
|
||||
_self set ["isLoaded", false];
|
||||
_self set ["lastSave", time];
|
||||
|
||||
systemChat format ["Store class initialized for %1", (name player)];
|
||||
systemChat format ["Store class initialized for %1", name player];
|
||||
diag_log "[FORGE:Client:Store] Store Class Initialized!";
|
||||
}],
|
||||
["buildUIPayload", {
|
||||
["buildUIPayload", compileFinal {
|
||||
private _workspace = _self getOrDefault ["workspace", createHashMap];
|
||||
|
||||
createHashMapFromArray [
|
||||
@ -58,7 +57,38 @@ GVAR(StoreClass) = createHashMapObject [[
|
||||
]],
|
||||
["cartItems", []]
|
||||
]
|
||||
}]
|
||||
]];
|
||||
}],
|
||||
["formatPriceValue", compileFinal {
|
||||
params [["_priceValue", 0, [0]]];
|
||||
|
||||
format ["$%1", [_priceValue max 0] call BIS_fnc_numberText]
|
||||
}],
|
||||
["calculateItemPrice", compileFinal {
|
||||
params [
|
||||
["_cfg", configNull, [configNull]],
|
||||
["_isVehicle", false, [false]]
|
||||
];
|
||||
|
||||
if (isNull _cfg) exitWith { "$50" };
|
||||
|
||||
private _mass = 0;
|
||||
private _priceValue = 0;
|
||||
|
||||
if (_isVehicle) then {
|
||||
_priceValue = getNumber (_cfg >> "cost");
|
||||
} else {
|
||||
_mass = getNumber (_cfg >> "ItemInfo" >> "mass");
|
||||
if (_mass <= 0) then {
|
||||
_mass = getNumber (_cfg >> "mass");
|
||||
};
|
||||
|
||||
_priceValue = ceil ((_mass max 0) * 0.1);
|
||||
};
|
||||
|
||||
_priceValue = _priceValue max 50;
|
||||
_self call ["formatPriceValue", [_priceValue]]
|
||||
}]
|
||||
];
|
||||
|
||||
GVAR(StoreClass) = createHashMapObject [GVAR(StoreBaseClass)];
|
||||
GVAR(StoreClass)
|
||||
|
||||
@ -4,15 +4,19 @@
|
||||
* File: fnc_initStoreUIBridge.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-03-10
|
||||
* Last Update: 2026-03-11
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* Initializes the store UI bridge for browser control state and event routing.
|
||||
* Initializes the store UI bridge for browser control state, event routing, and catalog queries.
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "StoreUIBridgeBaseClass"],
|
||||
["#create", compileFinal {
|
||||
_self set ["catalogCache", createHashMap];
|
||||
}],
|
||||
["getActiveBrowserControl", compileFinal {
|
||||
private _display = uiNamespace getVariable ["RscStore", displayNull];
|
||||
if (isNull _display) exitWith { controlNull };
|
||||
@ -20,11 +24,7 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
_display displayCtrl 1004
|
||||
}],
|
||||
["execBridge", compileFinal {
|
||||
params [
|
||||
["_control", controlNull, [controlNull]],
|
||||
["_fnName", "", [""]],
|
||||
["_payload", createHashMap, [createHashMap]]
|
||||
];
|
||||
params [["_control", controlNull, [controlNull]], ["_fnName", "", [""]], ["_payload", createHashMap, [createHashMap]]];
|
||||
|
||||
if (isNull _control || { _fnName isEqualTo "" }) exitWith { false };
|
||||
|
||||
@ -34,18 +34,12 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
true
|
||||
}],
|
||||
["sendBridgeEvent", compileFinal {
|
||||
params [
|
||||
["_event", "", [""]],
|
||||
["_data", createHashMap, [createHashMap]],
|
||||
["_control", controlNull, [controlNull]]
|
||||
];
|
||||
params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]], ["_control", controlNull, [controlNull]]];
|
||||
|
||||
if (_event isEqualTo "") exitWith { false };
|
||||
|
||||
private _targetControl = _control;
|
||||
if (isNull _targetControl) then {
|
||||
_targetControl = _self call ["getActiveBrowserControl", []];
|
||||
};
|
||||
if (isNull _targetControl) then { _targetControl = _self call ["getActiveBrowserControl", []]; };
|
||||
|
||||
if (isNull _targetControl) exitWith { false };
|
||||
|
||||
@ -54,6 +48,219 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["data", _data]
|
||||
]]]
|
||||
}],
|
||||
["isVisibleConfig", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]]];
|
||||
|
||||
isClass _cfg
|
||||
&& { getNumber (_cfg >> "scope") >= 2 }
|
||||
&& { (getText (_cfg >> "displayName")) isNotEqualTo "" }
|
||||
}],
|
||||
["buildDescription", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]], ["_fallback", "", [""]]];
|
||||
|
||||
private _description = getText (_cfg >> "descriptionShort");
|
||||
if (_description isEqualTo "") then { _description = _fallback; };
|
||||
|
||||
_description
|
||||
}],
|
||||
["buildItem", compileFinal {
|
||||
params [["_cfg", configNull, [configNull]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]], ["_imageField", "picture", [""]], ["_isVehicle", false, [false]]];
|
||||
|
||||
if (isNull _cfg) exitWith { createHashMap };
|
||||
|
||||
private _className = configName _cfg;
|
||||
private _displayName = getText (_cfg >> "displayName");
|
||||
private _picture = getText (_cfg >> _imageField);
|
||||
if (_picture isEqualTo "" && { _imageField isNotEqualTo "picture" }) then { _picture = getText (_cfg >> "picture"); };
|
||||
|
||||
createHashMapFromArray [
|
||||
["className", _className],
|
||||
["code", _className],
|
||||
["name", _displayName],
|
||||
["description", _self call ["buildDescription", [_cfg, _fallbackDescription]]],
|
||||
["price", GVAR(StoreClass) call ["calculateItemPrice", [_cfg, _isVehicle]]],
|
||||
["image", _picture],
|
||||
["type", _typeLabel]
|
||||
]
|
||||
}],
|
||||
["appendCfgWeaponsByItemInfoType", compileFinal {
|
||||
params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "ItemInfo" >> "type") isEqualTo _itemInfoType }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["appendCfgWeaponsByType", compileFinal {
|
||||
params [["_items", [], [[]]], ["_weaponType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "type") isEqualTo _weaponType }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["appendCfgVehiclesByKind", compileFinal {
|
||||
params [["_items", [], [[]]], ["_baseClass", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]];
|
||||
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { getNumber (_cfg >> "isBackpack") isEqualTo 0 }
|
||||
&& { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) }
|
||||
&& { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) }
|
||||
&& { _className isKindOf [_baseClass, configFile >> "CfgVehicles"] }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, _fallbackDescription, "editorPreview", true]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
|
||||
|
||||
_items
|
||||
}],
|
||||
["scanCategoryItems", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
private _categoryKey = toLowerANSI _category;
|
||||
if (_categoryKey isEqualTo "") exitWith { [] };
|
||||
if (isNil QGVAR(StoreClass)) exitWith { [] };
|
||||
|
||||
private _items = [];
|
||||
|
||||
switch (_categoryKey) do {
|
||||
case "uniforms": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 801, "Uniform", "Live uniform entry generated from the game inventory."]];
|
||||
};
|
||||
case "headgear": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 605, "Headgear", "Live headgear entry generated from the game inventory."]];
|
||||
};
|
||||
case "vests": {
|
||||
_items = _self call ["appendCfgWeaponsByItemInfoType", [_items, 701, "Vest", "Live vest entry generated from the game inventory."]];
|
||||
};
|
||||
case "facewear": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (_self call ["isVisibleConfig", [_cfg]]) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, "Facewear", "Live facewear entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgGlasses"));
|
||||
};
|
||||
case "ammo": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
if (_self call ["isVisibleConfig", [_cfg]]) then {
|
||||
_items pushBack (_self call ["buildItem", [_cfg, "Magazine", "Live ammunition entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgMagazines"));
|
||||
};
|
||||
case "items": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
private _itemType = [_className] call BIS_fnc_itemType;
|
||||
private _group = _itemType param [0, ""];
|
||||
private _kind = _itemType param [1, ""];
|
||||
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { _group in ["Item", "Equipment"] }
|
||||
&& { !(_kind in ["Uniform", "Vest", "Headgear"]) }
|
||||
) then {
|
||||
private _typeLabel = [_kind, "Item"] select (_kind isEqualTo "");
|
||||
_items pushBack (_self call ["buildItem", [_cfg, _typeLabel, "Live utility entry generated from the game inventory."]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgWeapons"));
|
||||
};
|
||||
case "primary": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 1, "Primary Weapon", "Live primary weapon entry generated from the game inventory."]];
|
||||
};
|
||||
case "handgun": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 2, "Handgun", "Live sidearm entry generated from the game inventory."]];
|
||||
};
|
||||
case "secondary": {
|
||||
_items = _self call ["appendCfgWeaponsByType", [_items, 4, "Launcher", "Live launcher entry generated from the game inventory."]];
|
||||
};
|
||||
case "cars": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Car", "Vehicle", "Live wheeled vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "armor": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Tank", "Vehicle", "Live armored vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "helis": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Helicopter", "Aircraft", "Live helicopter entry generated from the game inventory."]];
|
||||
};
|
||||
case "planes": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Plane", "Aircraft", "Live fixed-wing entry generated from the game inventory."]];
|
||||
};
|
||||
case "naval": {
|
||||
_items = _self call ["appendCfgVehiclesByKind", [_items, "Ship", "Naval", "Live naval vehicle entry generated from the game inventory."]];
|
||||
};
|
||||
case "other": {
|
||||
{
|
||||
private _cfg = _x;
|
||||
private _className = configName _cfg;
|
||||
private _isSupportedVehicle = _className isKindOf ["AllVehicles", configFile >> "CfgVehicles"];
|
||||
private _isKnownCategory =
|
||||
_className isKindOf ["Car", configFile >> "CfgVehicles"]
|
||||
|| { _className isKindOf ["Tank", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Helicopter", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Plane", configFile >> "CfgVehicles"] }
|
||||
|| { _className isKindOf ["Ship", configFile >> "CfgVehicles"] };
|
||||
|
||||
if (
|
||||
_self call ["isVisibleConfig", [_cfg]]
|
||||
&& { _isSupportedVehicle }
|
||||
&& { !_isKnownCategory }
|
||||
&& { getNumber (_cfg >> "isBackpack") isEqualTo 0 }
|
||||
&& { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) }
|
||||
&& { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) }
|
||||
) then {
|
||||
_items pushBack (_self call ["buildItem", [
|
||||
_cfg,
|
||||
"Special Vehicle",
|
||||
"Live specialty vehicle entry generated from the game inventory.",
|
||||
"editorPreview",
|
||||
true
|
||||
]]);
|
||||
};
|
||||
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
|
||||
};
|
||||
};
|
||||
|
||||
private _sortedItems = _items apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] };
|
||||
|
||||
_sortedItems sort true;
|
||||
_sortedItems apply { _x select 1 }
|
||||
}],
|
||||
["buildCategoryItems", compileFinal {
|
||||
params [["_category", "", [""]]];
|
||||
|
||||
private _categoryKey = toLowerANSI _category;
|
||||
if (_categoryKey isEqualTo "") exitWith { [] };
|
||||
|
||||
private _catalogCache = _self getOrDefault ["catalogCache", createHashMap];
|
||||
if (_categoryKey in (keys _catalogCache)) exitWith { _catalogCache get _categoryKey };
|
||||
|
||||
private _items = _self call ["scanCategoryItems", [_categoryKey]];
|
||||
_catalogCache set [_categoryKey, _items];
|
||||
_self set ["catalogCache", _catalogCache];
|
||||
|
||||
_items
|
||||
}],
|
||||
["handleReady", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
@ -65,23 +272,40 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
|
||||
_self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]];
|
||||
}],
|
||||
["handleCategoryRequest", compileFinal {
|
||||
params [["_data", createHashMap, [createHashMap]]];
|
||||
|
||||
private _category = toLowerANSI (_data getOrDefault ["category", ""]);
|
||||
if (_category isEqualTo "") exitWith {
|
||||
_self call ["sendBridgeEvent", ["store::category::failure", createHashMapFromArray [
|
||||
["message", "No store category was provided."]
|
||||
]]];
|
||||
};
|
||||
|
||||
if (isNil QGVAR(StoreClass)) exitWith {
|
||||
_self call ["sendBridgeEvent", ["store::category::failure", createHashMapFromArray [
|
||||
["category", _category],
|
||||
["message", "Store data is unavailable."]
|
||||
]]];
|
||||
};
|
||||
|
||||
private _items = _self call ["buildCategoryItems", [_category]];
|
||||
|
||||
diag_log format ["[FORGE:Client:Store] Category request handled for %1 with %2 item(s).", _category, count _items];
|
||||
|
||||
_self call ["sendBridgeEvent", ["store::category::hydrate", createHashMapFromArray [
|
||||
["category", _category],
|
||||
["items", _items]
|
||||
]]];
|
||||
}],
|
||||
["handleCheckoutRequest", compileFinal {
|
||||
params [["_data", createHashMap, [createHashMap]]];
|
||||
|
||||
private _items = _data getOrDefault ["items", []];
|
||||
private _message = format [
|
||||
"Checkout integration is not wired yet. Received %1 queued line(s).",
|
||||
count _items
|
||||
];
|
||||
private _message = format ["Checkout integration is not wired yet. Received %1 queued line(s).", count _items];
|
||||
|
||||
diag_log format [
|
||||
"[FORGE:Client:Store] Checkout request received: %1",
|
||||
_data
|
||||
];
|
||||
|
||||
_self call ["sendBridgeEvent", ["store::checkout::failure", createHashMapFromArray [
|
||||
["message", _message]
|
||||
]]];
|
||||
diag_log format ["[FORGE:Client:Store] Checkout request received: %1", _data];
|
||||
_self call ["sendBridgeEvent", ["store::checkout::failure", createHashMapFromArray [["message", _message]]]];
|
||||
}]
|
||||
];
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* File: fnc_openUI.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-01-28
|
||||
* Last Update: 2026-02-06
|
||||
* Last Update: 2026-03-11
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
|
||||
@ -27,6 +27,10 @@
|
||||
return sendEvent("store::checkout::request", payload);
|
||||
}
|
||||
|
||||
function requestCategory(payload) {
|
||||
return sendEvent("store::category::request", payload);
|
||||
}
|
||||
|
||||
function notifyReady() {
|
||||
return sendEvent("store::ready", { loaded: true });
|
||||
}
|
||||
@ -60,6 +64,22 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::category::hydrate") {
|
||||
store.hydrateCategoryItems(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::category::failure") {
|
||||
store.finishCategoryRequest(payloadData.category || "");
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"error",
|
||||
payloadData.message || "Category request failed.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::checkout::failure") {
|
||||
store.setIsCheckingOut(false);
|
||||
if (StorefrontApp.actions) {
|
||||
@ -75,6 +95,7 @@
|
||||
sendEvent,
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
requestCategory,
|
||||
notifyReady,
|
||||
receive,
|
||||
};
|
||||
@ -82,6 +103,7 @@
|
||||
window.StoreUIBridge = {
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
requestCategory,
|
||||
notifyReady,
|
||||
receive,
|
||||
receiveHydrate: (data) => receive("store::hydrate", data),
|
||||
|
||||
@ -403,6 +403,7 @@ ${scopeSelector} .store-toast.is-error {
|
||||
CategoryGrid,
|
||||
SubcategoryGrid,
|
||||
ProductGrid,
|
||||
CatalogPager,
|
||||
} = StorefrontApp.componentFns;
|
||||
|
||||
if (state.view === "weapons" || state.view === "vehicles") {
|
||||
@ -425,23 +426,43 @@ ${scopeSelector} .store-toast.is-error {
|
||||
|
||||
if (state.view === "items") {
|
||||
const items = getters.getVisibleItems(state, catalog);
|
||||
const pagedItems = getters.getVisibleItemsPage(state, catalog);
|
||||
const pagination = getters.getCatalogPagination(state, catalog);
|
||||
const quantityByCode = state.cartItems.reduce((acc, item) => {
|
||||
acc[item.code] = item.quantity;
|
||||
return acc;
|
||||
}, {});
|
||||
const selectionKey = String(
|
||||
getters.getSelectionKey(state) || "",
|
||||
).toLowerCase();
|
||||
|
||||
return ProductGrid(
|
||||
items.length > 0
|
||||
? items.map((item) =>
|
||||
ProductCard(item, quantityByCode[item.code] || 0),
|
||||
)
|
||||
: EmptyStateCard({
|
||||
title: "No matching products",
|
||||
copy: "Your search filter excluded the available preview items for this category.",
|
||||
actionLabel: "Clear Search",
|
||||
onAction: () => actions.clearSearch(),
|
||||
}),
|
||||
);
|
||||
return [
|
||||
ProductGrid(
|
||||
state.isCatalogLoading &&
|
||||
state.catalogRequestKey === selectionKey &&
|
||||
items.length === 0
|
||||
? EmptyStateCard({
|
||||
title: "Loading inventory",
|
||||
copy: "Pulling live category items from the game engine.",
|
||||
})
|
||||
: items.length > 0
|
||||
? pagedItems.map((item) =>
|
||||
ProductCard(
|
||||
item,
|
||||
quantityByCode[item.code] || 0,
|
||||
),
|
||||
)
|
||||
: EmptyStateCard({
|
||||
title: "No category items",
|
||||
copy: state.searchQuery
|
||||
? "Your search filter excluded the live inventory returned for this category."
|
||||
: "The game engine did not return any items for this category yet.",
|
||||
actionLabel: "Clear Search",
|
||||
onAction: () => actions.clearSearch(),
|
||||
}),
|
||||
),
|
||||
items.length > 0 ? CatalogPager(pagination) : null,
|
||||
];
|
||||
}
|
||||
|
||||
const items = getters.getVisibleCategoryCards(state, catalog);
|
||||
|
||||
@ -2,12 +2,24 @@
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { h, ensureScopedStyle } = StorefrontApp.runtime;
|
||||
const actions = StorefrontApp.actions;
|
||||
const media = StorefrontApp.media;
|
||||
const scopeAttr = "data-ui-store-cards";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const cardsCss = `
|
||||
${scopeSelector}.catalog-grid-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
${scopeSelector}.catalog-pager-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@ -105,16 +117,20 @@ ${scopeSelector} .empty-state-copy {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-copy {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-card {
|
||||
min-height: 20rem;
|
||||
padding: 0.95rem;
|
||||
min-height: 15.5rem;
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-image {
|
||||
height: 9.5rem;
|
||||
height: 5.9rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px dashed rgb(18 54 93 / 0.24);
|
||||
background: linear-gradient(135deg, rgb(235 240 245) 0%, rgb(221 228 235) 100%);
|
||||
@ -125,18 +141,26 @@ ${scopeSelector} .product-image {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-image-asset {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-name {
|
||||
font-size: 1rem;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
color: var(--store-text-main);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-footer {
|
||||
@ -148,7 +172,7 @@ ${scopeSelector} .product-footer {
|
||||
}
|
||||
|
||||
${scopeSelector} .product-price {
|
||||
font-size: 1rem;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
color: var(--store-success);
|
||||
}
|
||||
@ -173,6 +197,49 @@ ${scopeSelector} .empty-state {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
padding: 0.55rem 0.9rem 0.75rem;
|
||||
border-top: 1px solid var(--store-accent-line);
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-pager-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-pager-summary {
|
||||
font-size: 0.86rem;
|
||||
color: var(--store-text-muted);
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-pager-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-pager-page {
|
||||
min-width: 5.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--store-accent);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-copy {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
${scopeSelector} .catalog-grid.is-categories,
|
||||
${scopeSelector} .catalog-grid.is-products {
|
||||
@ -194,13 +261,56 @@ ${scopeSelector} .empty-state {
|
||||
function createGrid(className, children) {
|
||||
ensureScopedStyle("storefront-cards", cardsCss);
|
||||
|
||||
if (
|
||||
className === "is-products" &&
|
||||
media &&
|
||||
typeof media.scheduleTextureObservation === "function"
|
||||
) {
|
||||
media.scheduleTextureObservation();
|
||||
}
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ [scopeAttr]: "" },
|
||||
h("div", { className: `catalog-grid ${className}` }, children),
|
||||
{
|
||||
[scopeAttr]: "",
|
||||
className: "catalog-grid-shell",
|
||||
},
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: `catalog-grid ${className}`,
|
||||
"data-preserve-scroll-id": "catalog-grid",
|
||||
},
|
||||
children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function formatDescription(description, fallbackValue) {
|
||||
const rawDescription = String(description || "").trim();
|
||||
if (!rawDescription) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const htmlDescription = rawDescription
|
||||
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
||||
.replace(/<\/\s*p\s*>/gi, "\n")
|
||||
.replace(/<\s*li\s*>/gi, "- ")
|
||||
.replace(/<\/\s*li\s*>/gi, "\n");
|
||||
const scratch = document.createElement("div");
|
||||
scratch.innerHTML = htmlDescription;
|
||||
|
||||
const textDescription = String(
|
||||
scratch.textContent || scratch.innerText || "",
|
||||
)
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
return textDescription || fallbackValue;
|
||||
}
|
||||
|
||||
StorefrontApp.componentFns.CategoryCard = function CategoryCard(category) {
|
||||
return h(
|
||||
"button",
|
||||
@ -248,21 +358,58 @@ ${scopeSelector} .empty-state {
|
||||
item,
|
||||
quantityInCart,
|
||||
) {
|
||||
const textureState =
|
||||
media && typeof media.getTextureState === "function"
|
||||
? media.getTextureState(item.image)
|
||||
: { isVisible: true };
|
||||
const textureSource =
|
||||
media && typeof media.getTextureSource === "function"
|
||||
? media.getTextureSource(item.image)
|
||||
: "";
|
||||
const description = formatDescription(
|
||||
item.description,
|
||||
item.className || item.code,
|
||||
);
|
||||
|
||||
return h(
|
||||
"article",
|
||||
{ className: "product-card" },
|
||||
h("div", { className: "product-image" }, "Image Placeholder"),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "product-image",
|
||||
"data-store-texture-path": item.image || "",
|
||||
},
|
||||
textureSource
|
||||
? h("img", {
|
||||
className: "product-image-asset",
|
||||
src: textureSource,
|
||||
alt: item.name,
|
||||
loading: "lazy",
|
||||
})
|
||||
: textureState.isVisible
|
||||
? "Loading Image"
|
||||
: "Image Placeholder",
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "product-meta" },
|
||||
h("span", { className: "product-code" }, item.code),
|
||||
h(
|
||||
"span",
|
||||
{ className: "product-code" },
|
||||
item.type || item.code || item.className,
|
||||
),
|
||||
h("strong", { className: "product-name" }, item.name),
|
||||
),
|
||||
h("p", { className: "product-copy" }, item.description),
|
||||
h("p", { className: "product-copy" }, description),
|
||||
h(
|
||||
"div",
|
||||
{ className: "product-footer" },
|
||||
h("span", { className: "product-price" }, item.price),
|
||||
h(
|
||||
"span",
|
||||
{ className: "product-price" },
|
||||
item.price || "Pending",
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
@ -332,4 +479,68 @@ ${scopeSelector} .empty-state {
|
||||
StorefrontApp.componentFns.ProductGrid = function ProductGrid(children) {
|
||||
return createGrid("is-products", children);
|
||||
};
|
||||
|
||||
StorefrontApp.componentFns.CatalogPager = function CatalogPager({
|
||||
currentPage,
|
||||
totalPages,
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalItems,
|
||||
}) {
|
||||
ensureScopedStyle("storefront-cards", cardsCss);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
[scopeAttr]: "",
|
||||
className: "catalog-pager-shell",
|
||||
},
|
||||
h(
|
||||
"div",
|
||||
{ className: "catalog-pager" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "catalog-pager-meta" },
|
||||
h("span", { className: "card-kicker" }, "Catalog Page"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "catalog-pager-summary" },
|
||||
totalItems > 0
|
||||
? `Showing ${startIndex}-${endIndex} of ${totalItems} items`
|
||||
: "No items available",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "catalog-pager-actions" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-secondary",
|
||||
disabled: currentPage <= 1,
|
||||
onClick: () => actions.goToPreviousCatalogPage(),
|
||||
},
|
||||
"Previous",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "catalog-pager-page" },
|
||||
`Page ${currentPage} / ${totalPages}`,
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-secondary",
|
||||
disabled: currentPage >= totalPages,
|
||||
onClick: () =>
|
||||
actions.goToNextCatalogPage(totalPages),
|
||||
},
|
||||
"Next",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
})();
|
||||
|
||||
@ -301,7 +301,10 @@ ${scopeSelector} .cart-empty {
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-lines" },
|
||||
{
|
||||
className: "cart-lines",
|
||||
"data-preserve-scroll-id": "cart-lines",
|
||||
},
|
||||
summary.lineCount > 0
|
||||
? state.cartItems.map((item) =>
|
||||
h(
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
const styleFiles = ["style.css"];
|
||||
const scriptFiles = [
|
||||
"runtime.js",
|
||||
"media.js",
|
||||
"data.js",
|
||||
"logic/store.js",
|
||||
"pages/StoreView.js",
|
||||
|
||||
@ -21,10 +21,12 @@
|
||||
|
||||
function applySearchQuery(value) {
|
||||
store.setSearchQuery(String(value || "").trim());
|
||||
store.resetCatalogPage();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
store.setSearchQuery("");
|
||||
store.resetCatalogPage();
|
||||
}
|
||||
|
||||
function toggleCart() {
|
||||
@ -52,12 +54,87 @@
|
||||
return store.navigateToBreadcrumb(target);
|
||||
}
|
||||
|
||||
function scrollCatalogToTop() {
|
||||
const catalogGrid = document.querySelector(
|
||||
'[data-preserve-scroll-id="catalog-grid"]',
|
||||
);
|
||||
if (catalogGrid) {
|
||||
catalogGrid.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCategory(category) {
|
||||
store.selectCategory(category);
|
||||
scrollCatalogToTop();
|
||||
|
||||
if (!["weapons", "vehicles"].includes(String(category || ""))) {
|
||||
requestCategoryItems(category);
|
||||
}
|
||||
}
|
||||
|
||||
function selectSubcategory(subcategory, slotType) {
|
||||
store.selectSubcategory(subcategory, slotType);
|
||||
scrollCatalogToTop();
|
||||
requestCategoryItems(subcategory);
|
||||
}
|
||||
|
||||
function goToCatalogPage(page) {
|
||||
store.setCatalogPageNumber(page);
|
||||
scrollCatalogToTop();
|
||||
}
|
||||
|
||||
function goToNextCatalogPage(totalPages) {
|
||||
const currentPage = Number(store.getCatalogPage() || 1);
|
||||
const lastPage = Math.max(1, Number(totalPages || 1));
|
||||
if (currentPage >= lastPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
goToCatalogPage(currentPage + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function goToPreviousCatalogPage() {
|
||||
const currentPage = Number(store.getCatalogPage() || 1);
|
||||
if (currentPage <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
goToCatalogPage(currentPage - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function requestCategoryItems(category) {
|
||||
const categoryKey = String(category || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!categoryKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cachedItems = store.getCatalogItemsByKey();
|
||||
if (Array.isArray(cachedItems[categoryKey])) {
|
||||
store.finishCategoryRequest("");
|
||||
return true;
|
||||
}
|
||||
|
||||
store.startCategoryRequest(categoryKey);
|
||||
|
||||
const bridge = StorefrontApp.bridge;
|
||||
if (!bridge || typeof bridge.requestCategory !== "function") {
|
||||
store.finishCategoryRequest(categoryKey);
|
||||
showNotice("error", "Store bridge is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const sent = bridge.requestCategory({ category: categoryKey });
|
||||
if (!sent) {
|
||||
store.finishCategoryRequest(categoryKey);
|
||||
showNotice("error", "Category request bridge is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addToCart(item) {
|
||||
@ -173,6 +250,9 @@
|
||||
navigateToBreadcrumb,
|
||||
selectCategory,
|
||||
selectSubcategory,
|
||||
goToCatalogPage,
|
||||
goToNextCatalogPage,
|
||||
goToPreviousCatalogPage,
|
||||
addToCart,
|
||||
incrementCartItem,
|
||||
decrementCartItem,
|
||||
|
||||
@ -16,6 +16,13 @@
|
||||
[this.getCartOpen, this.setCartOpen] = createSignal(false);
|
||||
[this.getSearchQuery, this.setSearchQuery] = createSignal("");
|
||||
[this.getCartItems, this.setCartItems] = createSignal([]);
|
||||
[this.getCatalogItemsByKey, this.setCatalogItemsByKey] =
|
||||
createSignal({});
|
||||
[this.getIsCatalogLoading, this.setIsCatalogLoading] =
|
||||
createSignal(false);
|
||||
[this.getCatalogRequestKey, this.setCatalogRequestKey] =
|
||||
createSignal("");
|
||||
[this.getCatalogPage, this.setCatalogPage] = createSignal(1);
|
||||
[this.getNotice, this.setNotice] = createSignal({
|
||||
type: "",
|
||||
text: "",
|
||||
@ -29,6 +36,9 @@
|
||||
this.setSelectedCategory("");
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setSelectedVehicleSlot("");
|
||||
this.setIsCatalogLoading(false);
|
||||
this.setCatalogRequestKey("");
|
||||
this.setCatalogPage(1);
|
||||
}
|
||||
|
||||
openWeaponsRoot() {
|
||||
@ -36,6 +46,9 @@
|
||||
this.setSelectedCategory("weapons");
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setSelectedVehicleSlot("");
|
||||
this.setIsCatalogLoading(false);
|
||||
this.setCatalogRequestKey("");
|
||||
this.setCatalogPage(1);
|
||||
}
|
||||
|
||||
openVehiclesRoot() {
|
||||
@ -43,12 +56,25 @@
|
||||
this.setSelectedCategory("vehicles");
|
||||
this.setSelectedVehicleSlot("");
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setIsCatalogLoading(false);
|
||||
this.setCatalogRequestKey("");
|
||||
this.setCatalogPage(1);
|
||||
}
|
||||
|
||||
resetCatalogPage() {
|
||||
this.setCatalogPage(1);
|
||||
}
|
||||
|
||||
setCatalogPageNumber(page) {
|
||||
const nextPage = Math.max(1, Number(page || 1));
|
||||
this.setCatalogPage(nextPage);
|
||||
}
|
||||
|
||||
selectCategory(category) {
|
||||
this.setSelectedCategory(category);
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setSelectedVehicleSlot("");
|
||||
this.setCatalogPage(1);
|
||||
|
||||
if (category === "weapons") {
|
||||
this.openWeaponsRoot();
|
||||
@ -72,9 +98,71 @@
|
||||
this.setSelectedVehicleSlot("");
|
||||
}
|
||||
|
||||
this.setCatalogPage(1);
|
||||
this.setView("items");
|
||||
}
|
||||
|
||||
startCategoryRequest(category) {
|
||||
const categoryKey = String(category || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!categoryKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setCatalogRequestKey(categoryKey);
|
||||
this.setIsCatalogLoading(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
finishCategoryRequest(category) {
|
||||
const categoryKey = String(category || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const activeKey = String(this.getCatalogRequestKey() || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!categoryKey || !activeKey || activeKey === categoryKey) {
|
||||
this.setCatalogRequestKey("");
|
||||
this.setIsCatalogLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
hydrateCategoryItems(payload) {
|
||||
const categoryKey = String(payload?.category || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const items = Array.isArray(payload?.items)
|
||||
? payload.items
|
||||
: [];
|
||||
|
||||
if (!categoryKey) {
|
||||
this.setCatalogRequestKey("");
|
||||
this.setIsCatalogLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCatalogItemsByKey((currentItemsByKey) =>
|
||||
Object.assign({}, currentItemsByKey, {
|
||||
[categoryKey]: items.map((item) => ({
|
||||
className: String(
|
||||
item.className || item.code || "",
|
||||
),
|
||||
code: String(item.code || item.className || ""),
|
||||
name: String(item.name || item.displayName || ""),
|
||||
description: String(item.description || ""),
|
||||
price: String(item.price || ""),
|
||||
image: String(item.image || ""),
|
||||
type: String(item.type || ""),
|
||||
quantity: Math.max(0, Number(item.quantity || 0)),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
this.finishCategoryRequest(categoryKey);
|
||||
}
|
||||
|
||||
navigateToBreadcrumb(target) {
|
||||
switch (target) {
|
||||
case "categories":
|
||||
@ -106,6 +194,10 @@
|
||||
);
|
||||
this.setCartOpen(false);
|
||||
this.setIsCheckingOut(false);
|
||||
this.setCatalogItemsByKey({});
|
||||
this.setCatalogRequestKey("");
|
||||
this.setIsCatalogLoading(false);
|
||||
this.setCatalogPage(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
266
arma/client/addons/store/ui/_site/media.js
Normal file
266
arma/client/addons/store/ui/_site/media.js
Normal file
@ -0,0 +1,266 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const runtime = StorefrontApp.runtime;
|
||||
const MAX_CONCURRENT_TEXTURES = 6;
|
||||
const RERENDER_DELAY_MS = 48;
|
||||
const textureCache = Object.create(null);
|
||||
const textureRequests = Object.create(null);
|
||||
const queuedTexturePaths = [];
|
||||
const queuedTextureLookup = Object.create(null);
|
||||
const visibleTexturePaths = Object.create(null);
|
||||
const observedTextureNodes = new WeakSet();
|
||||
let activeTextureRequests = 0;
|
||||
let observer = null;
|
||||
let observerRoot = null;
|
||||
let rerenderTimer = 0;
|
||||
|
||||
function normalizeTexturePath(path) {
|
||||
let normalizedPath = String(path || "").trim();
|
||||
if (!normalizedPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
while (
|
||||
normalizedPath.startsWith("\\") ||
|
||||
normalizedPath.startsWith("/")
|
||||
) {
|
||||
normalizedPath = normalizedPath.slice(1);
|
||||
}
|
||||
|
||||
if (!/\.[A-Za-z0-9]+$/.test(normalizedPath)) {
|
||||
normalizedPath += ".paa";
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function isBrowserTextureSource(path) {
|
||||
const value = String(path || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return (
|
||||
value.startsWith("data:image/") ||
|
||||
value.startsWith("blob:") ||
|
||||
value.startsWith("http://") ||
|
||||
value.startsWith("https://")
|
||||
);
|
||||
}
|
||||
|
||||
function finalizeTextureSource(path, source) {
|
||||
textureCache[path] = source;
|
||||
|
||||
scheduleRerender();
|
||||
}
|
||||
|
||||
function scheduleRerender() {
|
||||
if (rerenderTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
rerenderTimer = window.setTimeout(() => {
|
||||
rerenderTimer = 0;
|
||||
if (runtime && typeof runtime.rerender === "function") {
|
||||
runtime.rerender();
|
||||
}
|
||||
}, RERENDER_DELAY_MS);
|
||||
}
|
||||
|
||||
function pumpTextureQueue() {
|
||||
if (
|
||||
typeof A3API === "undefined" ||
|
||||
typeof A3API.RequestTexture !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
activeTextureRequests < MAX_CONCURRENT_TEXTURES &&
|
||||
queuedTexturePaths.length > 0
|
||||
) {
|
||||
const normalizedPath = queuedTexturePaths.shift();
|
||||
delete queuedTextureLookup[normalizedPath];
|
||||
|
||||
if (
|
||||
!normalizedPath ||
|
||||
textureCache[normalizedPath] !== undefined ||
|
||||
textureRequests[normalizedPath]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeTextureRequests += 1;
|
||||
textureRequests[normalizedPath] = Promise.resolve(
|
||||
A3API.RequestTexture(normalizedPath, 512),
|
||||
)
|
||||
.then((resolvedPath) => {
|
||||
const textureSource = String(resolvedPath || "").trim();
|
||||
|
||||
if (isBrowserTextureSource(textureSource)) {
|
||||
finalizeTextureSource(normalizedPath, textureSource);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[Store UI] Ignoring unsupported texture response.",
|
||||
normalizedPath,
|
||||
textureSource,
|
||||
);
|
||||
finalizeTextureSource(normalizedPath, "");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
"[Store UI] Failed to resolve texture.",
|
||||
normalizedPath,
|
||||
error,
|
||||
);
|
||||
finalizeTextureSource(normalizedPath, "");
|
||||
})
|
||||
.finally(() => {
|
||||
activeTextureRequests = Math.max(
|
||||
0,
|
||||
activeTextureRequests - 1,
|
||||
);
|
||||
delete textureRequests[normalizedPath];
|
||||
pumpTextureQueue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function queueTextureRequest(path) {
|
||||
if (!path || queuedTextureLookup[path] || textureRequests[path]) {
|
||||
return;
|
||||
}
|
||||
|
||||
queuedTextureLookup[path] = true;
|
||||
queuedTexturePaths.push(path);
|
||||
pumpTextureQueue();
|
||||
}
|
||||
|
||||
function markTextureVisible(path) {
|
||||
const normalizedPath = normalizeTexturePath(path);
|
||||
if (!normalizedPath || visibleTexturePaths[normalizedPath]) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleTexturePaths[normalizedPath] = true;
|
||||
if (
|
||||
!isBrowserTextureSource(textureCache[normalizedPath]) &&
|
||||
!textureRequests[normalizedPath]
|
||||
) {
|
||||
queueTextureRequest(normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureObserver() {
|
||||
const currentRoot = document.querySelector(".catalog-grid");
|
||||
if (typeof IntersectionObserver !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (observer && observerRoot === currentRoot) {
|
||||
return observer;
|
||||
}
|
||||
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
observerRoot = currentRoot;
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPath = entry.target.getAttribute(
|
||||
"data-store-texture-path",
|
||||
);
|
||||
markTextureVisible(rawPath);
|
||||
observer.unobserve(entry.target);
|
||||
});
|
||||
},
|
||||
{
|
||||
root: currentRoot,
|
||||
rootMargin: "240px 0px",
|
||||
threshold: 0.01,
|
||||
},
|
||||
);
|
||||
|
||||
return observer;
|
||||
}
|
||||
|
||||
function observeTextureTargets() {
|
||||
const targets = document.querySelectorAll("[data-store-texture-path]");
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeObserver = ensureObserver();
|
||||
targets.forEach((target) => {
|
||||
if (observedTextureNodes.has(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
observedTextureNodes.add(target);
|
||||
|
||||
const rawPath = target.getAttribute("data-store-texture-path");
|
||||
if (!activeObserver) {
|
||||
markTextureVisible(rawPath);
|
||||
return;
|
||||
}
|
||||
|
||||
activeObserver.observe(target);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTextureObservation() {
|
||||
window.requestAnimationFrame(() => {
|
||||
observeTextureTargets();
|
||||
});
|
||||
}
|
||||
|
||||
function getTextureState(path) {
|
||||
const normalizedPath = normalizeTexturePath(path);
|
||||
return {
|
||||
path: normalizedPath,
|
||||
isVisible: Boolean(
|
||||
normalizedPath && visibleTexturePaths[normalizedPath],
|
||||
),
|
||||
isLoaded: Boolean(
|
||||
normalizedPath &&
|
||||
textureCache[normalizedPath] &&
|
||||
isBrowserTextureSource(textureCache[normalizedPath]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextureSource(path) {
|
||||
const normalizedPath = normalizeTexturePath(path);
|
||||
if (!normalizedPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (isBrowserTextureSource(path)) {
|
||||
textureCache[normalizedPath] = String(path).trim();
|
||||
return textureCache[normalizedPath];
|
||||
}
|
||||
|
||||
if (textureCache[normalizedPath] !== undefined) {
|
||||
return textureCache[normalizedPath];
|
||||
}
|
||||
|
||||
if (visibleTexturePaths[normalizedPath]) {
|
||||
queueTextureRequest(normalizedPath);
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
StorefrontApp.media = {
|
||||
getTextureState,
|
||||
getTextureSource,
|
||||
scheduleTextureObservation,
|
||||
};
|
||||
})();
|
||||
@ -1,5 +1,6 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const CATALOG_PAGE_SIZE = 24;
|
||||
|
||||
function getSelectionKey(state) {
|
||||
return (
|
||||
@ -56,6 +57,10 @@
|
||||
cartOpen: store.getCartOpen(),
|
||||
searchQuery: store.getSearchQuery(),
|
||||
cartItems: store.getCartItems(),
|
||||
catalogItemsByKey: store.getCatalogItemsByKey(),
|
||||
isCatalogLoading: store.getIsCatalogLoading(),
|
||||
catalogRequestKey: store.getCatalogRequestKey(),
|
||||
catalogPage: store.getCatalogPage(),
|
||||
isCheckingOut: store.getIsCheckingOut(),
|
||||
};
|
||||
}
|
||||
@ -84,11 +89,14 @@
|
||||
const queryLabel = state.searchQuery
|
||||
? ` Filtered by "${state.searchQuery}".`
|
||||
: "";
|
||||
const loadingLabel = state.isCatalogLoading
|
||||
? " Pulling live inventory from the game engine."
|
||||
: "";
|
||||
|
||||
return {
|
||||
eyebrow: "Catalog Preview",
|
||||
title: formatTitle(label),
|
||||
copy: `Mock product cards with placeholder imagery sized for future filtering, search, and cart logic.${queryLabel}`,
|
||||
copy: `Live category inventory generated from the game engine for the selected department.${queryLabel}${loadingLabel}`,
|
||||
badge: "Preview Items",
|
||||
};
|
||||
}
|
||||
@ -163,18 +171,57 @@
|
||||
|
||||
function getVisibleItems(state, catalog) {
|
||||
const key = getSelectionKey(state);
|
||||
const items = catalog.previewItems[key] || [];
|
||||
const categoryKey = String(key || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const itemsByKey = state.catalogItemsByKey || {};
|
||||
const items = Array.isArray(itemsByKey[categoryKey])
|
||||
? itemsByKey[categoryKey]
|
||||
: [];
|
||||
|
||||
return items.filter((item) =>
|
||||
matchesQuery(state.searchQuery, [
|
||||
item.className,
|
||||
item.code,
|
||||
item.name,
|
||||
item.description,
|
||||
item.price,
|
||||
item.type,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function getCatalogPagination(state, catalog) {
|
||||
const totalItems = getVisibleItems(state, catalog).length;
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(totalItems / CATALOG_PAGE_SIZE),
|
||||
);
|
||||
const currentPage = Math.min(
|
||||
totalPages,
|
||||
Math.max(1, Number(state.catalogPage || 1)),
|
||||
);
|
||||
|
||||
return {
|
||||
pageSize: CATALOG_PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages,
|
||||
currentPage,
|
||||
startIndex:
|
||||
totalItems === 0
|
||||
? 0
|
||||
: (currentPage - 1) * CATALOG_PAGE_SIZE + 1,
|
||||
endIndex: Math.min(currentPage * CATALOG_PAGE_SIZE, totalItems),
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleItemsPage(state, catalog) {
|
||||
const items = getVisibleItems(state, catalog);
|
||||
const pagination = getCatalogPagination(state, catalog);
|
||||
const startOffset = (pagination.currentPage - 1) * pagination.pageSize;
|
||||
return items.slice(startOffset, startOffset + pagination.pageSize);
|
||||
}
|
||||
|
||||
function summarizeCart(cartItems) {
|
||||
const itemCount = cartItems.reduce(
|
||||
(sum, item) => sum + Number(item.quantity || 0),
|
||||
@ -205,6 +252,8 @@
|
||||
getVisibleCategoryCards,
|
||||
getVisibleSubcategoryCards,
|
||||
getVisibleItems,
|
||||
getVisibleItemsPage,
|
||||
getCatalogPagination,
|
||||
summarizeCart,
|
||||
};
|
||||
})();
|
||||
|
||||
@ -102,6 +102,42 @@
|
||||
let rootComponent = null;
|
||||
const injectedStyles = new Set();
|
||||
|
||||
function captureScrollState(container) {
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
container.querySelectorAll("[data-preserve-scroll-id]"),
|
||||
).map((node) => ({
|
||||
id: node.getAttribute("data-preserve-scroll-id"),
|
||||
scrollTop: node.scrollTop,
|
||||
scrollLeft: node.scrollLeft,
|
||||
}));
|
||||
}
|
||||
|
||||
function restoreScrollState(container, entries) {
|
||||
if (!container || !Array.isArray(entries) || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = container.querySelector(
|
||||
`[data-preserve-scroll-id="${entry.id}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollTop = Number(entry.scrollTop || 0);
|
||||
target.scrollLeft = Number(entry.scrollLeft || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function render(component, container) {
|
||||
rootContainer = container;
|
||||
rootComponent = component;
|
||||
@ -113,8 +149,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollState = captureScrollState(rootContainer);
|
||||
rootContainer.innerHTML = "";
|
||||
rootContainer.appendChild(rootComponent());
|
||||
restoreScrollState(rootContainer, scrollState);
|
||||
}
|
||||
|
||||
function ensureScopedStyle(id, cssText) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user