Merge development into master: shared Web UI runtime, bridge-driven UIs, and server-authoritative store flow #1

Merged
J.Schmidt92 merged 37 commits from development into master 2026-03-14 20:12:08 -05:00
14 changed files with 1105 additions and 71 deletions
Showing only changes of commit 09ab290b5a - Show all commits

View File

@ -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]; };
};

View File

@ -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)

View File

@ -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]]]];
}]
];

View File

@ -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:

View File

@ -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),

View File

@ -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),
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 matching products",
copy: "Your search filter excluded the available preview items for this category.",
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);

View File

@ -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",
),
),
),
);
};
})();

View File

@ -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(

View File

@ -9,6 +9,7 @@
const styleFiles = ["style.css"];
const scriptFiles = [
"runtime.js",
"media.js",
"data.js",
"logic/store.js",
"pages/StoreView.js",

View File

@ -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,

View File

@ -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);
}
}

View 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,
};
})();

View File

@ -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,
};
})();

View File

@ -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) {