From 09ab290b5a2fe0563485ce79d53a66b2d2ac60ab Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Tue, 10 Mar 2026 22:20:38 -0500 Subject: [PATCH] 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 --- .../store/functions/fnc_handleUIEvents.sqf | 11 +- .../store/functions/fnc_initStoreClass.sqf | 48 ++- .../store/functions/fnc_initStoreUIBridge.sqf | 276 ++++++++++++++++-- .../addons/store/functions/fnc_openUI.sqf | 2 +- arma/client/addons/store/ui/_site/bridge.js | 22 ++ .../store/ui/_site/components/AppShell.js | 45 ++- .../addons/store/ui/_site/components/cards.js | 237 ++++++++++++++- .../addons/store/ui/_site/components/cart.js | 5 +- arma/client/addons/store/ui/_site/index.html | 1 + .../addons/store/ui/_site/logic/events.js | 80 +++++ .../addons/store/ui/_site/logic/store.js | 92 ++++++ arma/client/addons/store/ui/_site/media.js | 266 +++++++++++++++++ .../addons/store/ui/_site/pages/StoreView.js | 53 +++- arma/client/addons/store/ui/_site/runtime.js | 38 +++ 14 files changed, 1105 insertions(+), 71 deletions(-) create mode 100644 arma/client/addons/store/ui/_site/media.js diff --git a/arma/client/addons/store/functions/fnc_handleUIEvents.sqf b/arma/client/addons/store/functions/fnc_handleUIEvents.sqf index 194ec82..5ee1dfe 100644 --- a/arma/client/addons/store/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/store/functions/fnc_handleUIEvents.sqf @@ -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]; }; }; diff --git a/arma/client/addons/store/functions/fnc_initStoreClass.sqf b/arma/client/addons/store/functions/fnc_initStoreClass.sqf index eb493d3..8d06a3e 100644 --- a/arma/client/addons/store/functions/fnc_initStoreClass.sqf +++ b/arma/client/addons/store/functions/fnc_initStoreClass.sqf @@ -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) diff --git a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf b/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf index 362e40f..3bb1a44 100644 --- a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf +++ b/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf @@ -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]]]]; }] ]; diff --git a/arma/client/addons/store/functions/fnc_openUI.sqf b/arma/client/addons/store/functions/fnc_openUI.sqf index f5988ec..e9b4aba 100644 --- a/arma/client/addons/store/functions/fnc_openUI.sqf +++ b/arma/client/addons/store/functions/fnc_openUI.sqf @@ -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: diff --git a/arma/client/addons/store/ui/_site/bridge.js b/arma/client/addons/store/ui/_site/bridge.js index 57d3418..3f61db0 100644 --- a/arma/client/addons/store/ui/_site/bridge.js +++ b/arma/client/addons/store/ui/_site/bridge.js @@ -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), diff --git a/arma/client/addons/store/ui/_site/components/AppShell.js b/arma/client/addons/store/ui/_site/components/AppShell.js index 8af37a6..ba997a4 100644 --- a/arma/client/addons/store/ui/_site/components/AppShell.js +++ b/arma/client/addons/store/ui/_site/components/AppShell.js @@ -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); diff --git a/arma/client/addons/store/ui/_site/components/cards.js b/arma/client/addons/store/ui/_site/components/cards.js index 5667912..fecf0cd 100644 --- a/arma/client/addons/store/ui/_site/components/cards.js +++ b/arma/client/addons/store/ui/_site/components/cards.js @@ -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", + ), + ), + ), + ); + }; })(); diff --git a/arma/client/addons/store/ui/_site/components/cart.js b/arma/client/addons/store/ui/_site/components/cart.js index 6ab6a3d..68f6ff8 100644 --- a/arma/client/addons/store/ui/_site/components/cart.js +++ b/arma/client/addons/store/ui/_site/components/cart.js @@ -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( diff --git a/arma/client/addons/store/ui/_site/index.html b/arma/client/addons/store/ui/_site/index.html index 61b8498..5ce3d9a 100644 --- a/arma/client/addons/store/ui/_site/index.html +++ b/arma/client/addons/store/ui/_site/index.html @@ -9,6 +9,7 @@ const styleFiles = ["style.css"]; const scriptFiles = [ "runtime.js", + "media.js", "data.js", "logic/store.js", "pages/StoreView.js", diff --git a/arma/client/addons/store/ui/_site/logic/events.js b/arma/client/addons/store/ui/_site/logic/events.js index 0754336..3ae085b 100644 --- a/arma/client/addons/store/ui/_site/logic/events.js +++ b/arma/client/addons/store/ui/_site/logic/events.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, diff --git a/arma/client/addons/store/ui/_site/logic/store.js b/arma/client/addons/store/ui/_site/logic/store.js index 7c9cb7c..ab7e05f 100644 --- a/arma/client/addons/store/ui/_site/logic/store.js +++ b/arma/client/addons/store/ui/_site/logic/store.js @@ -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); } } diff --git a/arma/client/addons/store/ui/_site/media.js b/arma/client/addons/store/ui/_site/media.js new file mode 100644 index 0000000..48917db --- /dev/null +++ b/arma/client/addons/store/ui/_site/media.js @@ -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, + }; +})(); diff --git a/arma/client/addons/store/ui/_site/pages/StoreView.js b/arma/client/addons/store/ui/_site/pages/StoreView.js index f391260..3224d08 100644 --- a/arma/client/addons/store/ui/_site/pages/StoreView.js +++ b/arma/client/addons/store/ui/_site/pages/StoreView.js @@ -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, }; })(); diff --git a/arma/client/addons/store/ui/_site/runtime.js b/arma/client/addons/store/ui/_site/runtime.js index dd2988b..f3879fb 100644 --- a/arma/client/addons/store/ui/_site/runtime.js +++ b/arma/client/addons/store/ui/_site/runtime.js @@ -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) {