diff --git a/arma/client/addons/common/README.md b/arma/client/addons/common/README.md index f6c181a..05fa06e 100644 --- a/arma/client/addons/common/README.md +++ b/arma/client/addons/common/README.md @@ -1,3 +1,5 @@ # forge_client_common Common functionality shared between addons. + +See [WEB_UI_FRAMEWORK.md](./WEB_UI_FRAMEWORK.md) for the proposed shared `CT_WEBBROWSER` UI framework layout and API. diff --git a/arma/client/addons/common/WEB_UI_FRAMEWORK.md b/arma/client/addons/common/WEB_UI_FRAMEWORK.md new file mode 100644 index 0000000..be0077f --- /dev/null +++ b/arma/client/addons/common/WEB_UI_FRAMEWORK.md @@ -0,0 +1,991 @@ +# Web UI Framework Proposal + +## Goal + +Create a shared web UI framework inside `forge_client_common` that provides one browser runtime for all `CT_WEBBROWSER` interfaces: + +- store +- bank +- garage +- org +- actor +- notifications + +The framework should standardize: + +- browser bootstrapping +- Arma to JS messaging +- JS to Arma messaging +- reactive state updates +- shared UI primitives +- asset loading +- teardown and remount behavior + +## Why This Should Live In `common` + +The current client web UIs already share the same underlying concerns: + +- `A3API.RequestFile` for loading scripts and styles +- `A3API.SendAlert` for outbound events +- `ctrlWebBrowserAction ["ExecJS", ...]` for inbound events +- full-page rerender on every signal update +- duplicated runtime and bridge code across addons + +That makes `forge_client_common` the right owner for: + +- the browser runtime +- the bridge contract +- reusable DOM helpers +- shared components and styles + +Each addon should keep only: + +- app-specific state +- app-specific event names +- app-specific SQF handlers +- app-specific views and theme assets + +## Constraints From `CT_WEBBROWSER` + +This framework should be built for the actual browser host, not for a generic modern frontend stack. + +- Browser engine should be treated as conservative Chromium/CEF. +- HTML is hosted inside the Arma browser control, not a normal web server app. +- Asset loading must work through `A3API.RequestFile`. +- Game integration must work through `A3API.SendAlert` and SQF `ExecJS`. +- Browser controls are opened and destroyed by UI displays, so mount/unmount must be explicit. +- Startup latency matters because players open these UIs interactively in-game. + +## Design Principles + +1. Keep the runtime small. +2. Avoid framework dependencies like React or Vue. +3. Prefer one shared bundle plus one app bundle per UI. +4. Support coarse-grained reactivity first, then targeted DOM patching where it matters. +5. Make the Arma bridge a first-class host adapter, not an afterthought. +6. Keep app logic plain JavaScript so views are easy to reason about. +7. Make every UI follow the same bootstrap contract. + +## Proposed Ownership + +### Common addon + +`forge_client_common` should own: + +- browser host adapter +- reactive runtime +- DOM renderer +- shared event bus +- base CSS tokens and utility classes +- shared components +- generic bootstrap helper +- SQF bridge base class + +### Feature addons + +Each feature addon should own: + +- one app entrypoint +- feature store/state +- feature bridge schema +- feature views/components +- feature-specific CSS layer +- feature SQF bridge subclass/instance + +## Proposed Folder Layout + +```text +arma/client/addons/common/ + ui/ + src/ + runtime.js + host.js + bridge.js + app.js + index.js + _site/ + forge-webui.js + functions/ + fnc_initWebUIBridge.sqf + fnc_openWebUI.sqf + fnc_sendWebUIEvent.sqf + README.md + WEB_UI_FRAMEWORK.md +``` + +Feature addon structure would then look like: + +```text +arma/client/addons/org/ + ui/ + _site/ + index.html + app.js + views/ + components/ + theme.css + functions/ + fnc_initOrgUIBridge.sqf + fnc_openUI.sqf + fnc_handleUIEvents.sqf +``` + +## Runtime API Sketch + +The shared runtime should expose a small API on `window.ForgeWebUI`. + +### Core API + +```js +ForgeWebUI = { + h, + text, + fragment, + signal, + computed, + effect, + batch, + mount, + unmount, + createApp, + createBridge, + createAssetLoader, + createNoticeCenter, +}; +``` + +### Reactive primitives + +```js +const count = signal(0); +const doubled = computed(() => count() * 2); + +effect(() => { + console.log("count", count()); +}); + +count.set(5); +``` + +Design notes: + +- `signal()` returns a getter function with `.set()` and `.update()`. +- `computed()` caches until one of its dependencies changes. +- `effect()` is for bridge sync, timers, DOM subscriptions, and cleanup. +- `batch()` groups several writes into one render pass. + +### DOM/rendering + +```js +function CounterView() { + return h("button", { + onClick() { + count.update((value) => value + 1); + } + }, `Count: ${count()}`); +} + +mount(document.getElementById("app"), CounterView); +``` + +The renderer should support: + +- keyed child reconciliation +- event binding +- text node updates +- conditional sections +- list rendering +- SVG nodes +- mount cleanup + +It should not rebuild the whole root on every write. + +## App Bootstrap Contract + +Every app should use the same bootstrap shape: + +```js +const app = ForgeWebUI.createApp({ + name: "org", + root: "#app", + setup({ host, bridge, assets, notices }) { + const store = createOrgStore(); + + bridge.on("org::sync", (payload) => { + store.hydrate(payload); + }); + + bridge.ready(); + + return () => OrgApp({ store, host, notices }); + } +}); + +app.start(); +``` + +Responsibilities: + +- `createApp()` locates the root node +- waits for DOM readiness +- sets up host services +- mounts the view +- wires bridge event listeners +- exposes teardown hooks + +## Host Adapter API + +The Arma host layer should hide `A3API` details behind one consistent service. + +```js +const host = { + isArma: true, + requestFile(path), + requestTexture(path, size), + send(event, data), + exec(name, data), + on(event, handler), + off(event, handler), + ready(data), + close(data), +}; +``` + +Behavior: + +- `send()` wraps `A3API.SendAlert(JSON.stringify(...))` +- `on()` and `off()` subscribe to messages injected from SQF +- `ready()` announces page readiness to SQF +- `close()` sends a standard close event +- if `A3API` is unavailable, fallback behavior supports local browser testing + +## JS Bridge Contract + +Each page should expose one stable bridge object to SQF: + +```js +window.ForgeBridge.receive({ + event: "org::sync", + data: { ... } +}); +``` + +This replaces app-specific globals like: + +- `StoreUIBridge` +- `OrgUIBridge` + +Recommended interface: + +```js +window.ForgeBridge = { + receive(payload), + receiveMany(events), + reset(), + ping(), +}; +``` + +Feature apps should register handlers with the shared bridge: + +```js +bridge.on("store::hydrate", handleHydrate); +bridge.on("store::checkout::success", handleCheckoutSuccess); +``` + +That removes duplicated payload parsing from each app bridge file. + +## SQF Bridge Base Class + +The SQF side should also be normalized in `common`. + +### Shared base responsibilities + +- find active browser control +- execute JS safely +- send `{ event, data }` payloads +- queue payloads until page ready +- flush pending payloads on ready +- standardize close handling +- standardize logging and diagnostics + +### SQF API sketch + +```sqf +GVAR(WebUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "WebUIBridgeBaseClass"], + ["#create", compileFinal { + _self set ["pendingEvents", []]; + _self set ["isReady", false]; + }], + ["getActiveBrowserControl", compileFinal { ... }], + ["execJS", compileFinal { ... }], + ["sendEvent", compileFinal { ... }], + ["queueEvent", compileFinal { ... }], + ["flushPendingEvents", compileFinal { ... }], + ["handleReady", compileFinal { ... }], + ["handleClose", compileFinal { ... }] +]; +``` + +Feature bridges like org or store would then extend only the behavior they need: + +- payload building +- server RPC dispatch +- feature response mapping + +## SQF Type Model With `createHashMapObject` + +The SQF side should lean into `createHashMapObject` instead of using plain hash maps for everything. + +This gives us: + +- inheritance through `#base` +- explicit type tagging through `#type` +- constructors through `#create` +- cleanup through `#delete` + +That is a strong fit for browser UI infrastructure because the UI layer already has clear object roles. + +### Recommended types + +At minimum, define these object families in `forge_client_common`: + +- `IWebUIBridge` +- `IWebUIScreen` +- `IWebUIRequest` +- `IWebUISubscription` + +Feature addons can then define their own types on top: + +- `OrgUIBridge` +- `StoreUIBridge` +- `BankUIBridge` +- `GarageUIBridge` + +### Example hierarchy + +```sqf +private _webUIBridgeDeclaration = [ + ["#type", "IWebUIBridge"], + ["#create", { ... }], + ["getActiveBrowserControl", { ... }], + ["sendEvent", { ... }], + ["handleReady", { ... }], + ["dispose", { ... }] +]; + +private _orgUIBridgeDeclaration = [ + ["#base", _webUIBridgeDeclaration], + ["#type", "OrgUIBridge"], + ["buildHydratePayload", { ... }], + ["handleCreditResponse", { ... }] +]; +``` + +Type checks then become straightforward: + +```sqf +if ("IWebUIBridge" in (_bridge get "#type")) then { + _bridge call ["sendEvent", ["ui::ping", createHashMap]]; +}; +``` + +### Why Example 4 matters + +Example 4 on the wiki shows the important lifecycle property: + +- constructor creates a resource +- object holds that resource +- destructor deletes that resource when the object is released + +That pattern maps directly to UI/session resources. + +### Good uses of `#delete` in this framework + +- clear pending request queues +- unregister display event handlers +- null out active browser control references +- stop polling/update loops +- remove temporary mission event handlers +- release temporary response trackers + +### Example use: request/response object + +```sqf +private _requestDeclaration = [ + ["#type", "IWebUIRequest"], + ["#create", { + params ["_requestId", "_onTimeout"]; + _self set ["requestId", _requestId]; + _self set ["onTimeout", _onTimeout]; + _self set ["isResolved", false]; + }], + ["resolve", { + _self set ["isResolved", true]; + }], + ["#delete", { + if !(_self getOrDefault ["isResolved", false]) then { + private _onTimeout = _self getOrDefault ["onTimeout", {}]; + call _onTimeout; + }; + }] +]; +``` + +This is the same concept as Example 4: + +- object owns a resource or responsibility +- when the object is released, cleanup happens automatically + +## Lifecycle Guidance + +Use destructors as a cleanup safety net, not as the only control path. + +Reason: + +- `#delete` runs when the last reference is removed +- that is useful, but not always the best moment for gameplay/UI logic + +Recommended pattern: + +1. expose an explicit `dispose` or `close` method +2. perform normal cleanup there +3. let `#delete` catch anything missed + +That keeps UI shutdown deterministic while still benefiting from automatic cleanup. + +## Typed Screen Objects + +We can also model each open browser UI as a typed screen object instead of just storing a control reference. + +Example: + +```sqf +private _screenDeclaration = [ + ["#type", "IWebUIScreen"], + ["#create", { + params ["_displayName", "_control"]; + _self set ["displayName", _displayName]; + _self set ["control", _control]; + _self set ["isReady", false]; + _self set ["pendingEvents", []]; + }], + ["markReady", { + _self set ["isReady", true]; + }], + ["queueEvent", { ... }], + ["flushPendingEvents", { ... }], + ["dispose", { + _self set ["pendingEvents", []]; + _self set ["control", controlNull]; + }] +]; +``` + +That gives us a cleaner split: + +- bridge object owns app-level behavior +- screen object owns one live browser control/session +- request objects own transient async work + +## Recommended Application To Current Addons + +The current org and store bridge objects already use `createHashMapObject`. + +This should evolve into: + +- one shared `IWebUIBridge` base declaration in `common` +- one shared `IWebUIScreen` declaration in `common` +- feature bridge types inheriting from `IWebUIBridge` +- optional transient request/session helper types where async cleanup matters + +That will make the SQF side more explicit, easier to test, and safer around UI teardown. + +## Event Naming + +Keep namespaced events. The current event style is good. + +Examples: + +- `org::ready` +- `org::sync` +- `org::create::request` +- `store::checkout::request` +- `notifications::ready` + +Standardize a small set of host-level events: + +- `ui::ready` +- `ui::close` +- `ui::error` +- `ui::ping` + +And keep feature events under their own namespace. + +## State Model + +The framework should support two store patterns: + +### Local signal store + +Good for: + +- form state +- modal state +- selection state +- optimistic UI flags + +### Domain store wrapper + +Good for: + +- hydrated server payloads +- catalog data +- actor action lists +- organization portal data + +Recommended store API: + +```js +function createStore(initialState) { + const state = signal(initialState); + + return { + get state() { + return state(); + }, + patch(partial) { + state.set({ ...state(), ...partial }); + }, + replace(next) { + state.set(next); + } + }; +} +``` + +## Component Update Model + +The framework should update component subtrees, not the full UI root. + +That means: + +- no browser page reload +- no `innerHTML = ""` on the app root for every state change +- only components that read changed state should rerender + +### Practical expectation + +Examples: + +- adding a member updates `MembersCard` and any member count badge +- granting a credit line updates `TreasuryCard` and the specific member row +- updating funds updates treasury summary components only +- showing a modal or notice updates only the overlay layer + +## Store Contract + +Each app store should expose three layers: + +1. domain state signals +2. derived selectors/computed values +3. mutation methods + +Recommended shape: + +```js +function createOrgStore() { + const org = signal({ + id: "", + name: "", + ownerUid: "", + }); + + const session = signal({ + actorUid: "", + actorName: "", + role: "", + ceo: false, + }); + + const treasury = signal({ + funds: 0, + reputation: 0, + creditLines: [], + }); + + const roster = signal({ + members: [], + }); + + const ui = signal({ + modal: null, + notices: [], + treasuryTab: "overview", + }); + + const memberCount = computed(() => roster().members.length); + const activeCreditCount = computed(() => treasury().creditLines.length); + + return { + org, + session, + treasury, + roster, + ui, + memberCount, + activeCreditCount, + hydrate(payload) { ... }, + addMember(member) { ... }, + removeMember(memberUid) { ... }, + upsertCreditLine(line) { ... }, + setFunds(amount) { ... }, + openModal(type, data) { ... }, + closeModal() { ... }, + }; +} +``` + +### Rules + +- component code reads signals directly from the store +- mutation methods are the only place that update domain state +- derived values use `computed()` instead of recalculating in every component +- UI state stays separate from domain state + +## Component Contract + +Components should be plain functions that subscribe only to the signals they read. + +Example: + +```js +function MembersCard({ store, actions }) { + const members = store.roster().members; + const canManageMembers = store.canManageMembers(); + + return Card({ + title: "Members", + body: List({ + items: members, + key: (member) => member.uid, + renderItem: (member) => + MemberRow({ + member, + canRemove: canManageMembers && !store.isProtectedMember(member), + onRemove: () => actions.removeMember(member.uid), + }), + }), + }); +} +``` + +In this model: + +- `MembersCard` rerenders when `roster().members` changes +- it does not rerender when treasury funds change +- `TreasuryCard` rerenders when `treasury()` changes +- modal components rerender when `ui().modal` changes + +## Patch-Oriented Mutations + +Interactive actions should prefer small patch events over full app hydration. + +Recommended event examples: + +- `org::member::added` +- `org::member::removed` +- `org::member::creditUpdated` +- `org::treasury::fundsUpdated` +- `org::notice::show` + +Initial load can still use a hydrate event: + +- `org::hydrate` + +But actions like assigning credit lines should not require rebuilding the full portal payload. + +Example: + +```js +bridge.on("org::member::creditUpdated", ({ memberUid, memberName, amount }) => { + store.upsertCreditLine({ + uid: memberUid, + member: memberName, + amount, + }); +}); +``` + +## List Reconciliation + +To make targeted updates real, list rendering must be keyed. + +Requirement: + +- every repeated domain item must have a stable key + +Examples: + +- members use `uid` +- credit lines use `uid` +- assets use `className` or inventory id +- fleet entries use vehicle id + +Without keyed reconciliation, a list change still forces the entire list DOM to be replaced. + +## Org UI Example + +Using the current organization portal as the model: + +### `MembersCard` + +Depends on: + +- `store.roster().members` +- membership permission selectors + +Should update when: + +- a member is added +- a member is removed +- a member name or role changes + +Should not update when: + +- treasury funds change +- a modal opens +- a fleet item changes + +### `TreasuryCard` + +Depends on: + +- `store.treasury().funds` +- `store.treasury().creditLines` +- treasury permissions +- `store.ui().treasuryTab` + +Should update when: + +- funds change +- a credit line is added or updated +- the user changes treasury tab + +Should not update when: + +- member roster changes unrelated to treasury display +- fleet changes + +### `ModalLayer` + +Depends on: + +- `store.ui().modal` + +Should update when: + +- a modal opens +- a modal closes +- modal payload changes + +Should not update when unrelated domain state changes. + +## Mutation Examples + +### Add member + +```js +addMember(member) { + this.roster.update((state) => ({ + ...state, + members: [...state.members, member], + })); +} +``` + +Only subscribers to `roster` rerender. + +### Update credit line + +```js +upsertCreditLine(nextLine) { + this.treasury.update((state) => { + const exists = state.creditLines.some((line) => line.uid === nextLine.uid); + + return { + ...state, + creditLines: exists + ? state.creditLines.map((line) => + line.uid === nextLine.uid ? nextLine : line + ) + : [...state.creditLines, nextLine], + }; + }); +} +``` + +Only subscribers to `treasury` rerender. + +## Bridge Response Strategy + +For responsive UIs, each server-backed action should define: + +- request event +- success patch event +- failure notice event or payload + +Example credit line flow: + +1. JS sends `org::credit::request` +2. SQF/server validates and persists +3. SQF sends: + - `org::member::creditUpdated` on success + - `org::credit::failure` on failure +4. JS store applies a targeted patch +5. `TreasuryCard` and any dependent member row update + +This is preferable to sending a full `org::sync` after every action. + +## Shared Components + +The common addon should provide plain, themeable primitives only. + +Recommended first set: + +- app shell +- title bar +- navbar +- modal +- notice/toast +- stat card +- empty state +- action row +- form field +- spinner +- error banner + +These should accept data and callbacks, not own business logic. + +## Styling Model + +Use layered CSS: + +1. common tokens +2. common primitives +3. feature theme +4. feature view styles + +The common layer should define: + +- spacing scale +- type scale +- colors +- elevation/shadows +- radius +- focus states +- motion durations + +Feature UIs should override tokens rather than rewriting primitive CSS. + +## Asset Loading + +The loader should support: + +- `A3API.RequestFile` +- `A3API.RequestTexture` +- local `fetch()` fallback for browser testing + +Recommended change: + +- stop loading many small scripts individually in production +- build one common runtime file and one feature app file +- keep source files split in repo, but ship bundled outputs into `_site` + +That reduces browser startup cost and simplifies ordering problems. + +## Error Handling + +The framework should standardize: + +- bridge unavailable errors +- malformed payload errors +- timeout handling for requests that expect responses +- visible in-UI notices for recoverable failures +- `console.error` plus `diag_log` friendly payloads + +Recommended bridge helper: + +```js +bridge.request("store::checkout::request", payload, { + pending: "Submitting order...", + timeoutMs: 15000, + onTimeout() { + notices.error("The checkout request timed out."); + } +}); +``` + +## Migration Plan + +### Phase 1 + +Extract common pieces without changing app behavior: + +- shared JS host adapter +- shared JS bridge +- shared signal/runtime +- shared SQF bridge base class + +### Phase 2 + +Migrate `org` and `store` first because they already use the same custom runtime pattern. + +### Phase 3 + +Migrate `bank`, `garage`, and `notifications`. + +### Phase 4 + +Migrate `actor`, which may need more event-heavy interaction handling. + +### Phase 5 + +Bundle all `_site` apps into production-ready outputs. + +## First Implementation Targets + +The first concrete files to build should be: + +1. `arma/client/addons/common/ui/src/host.js` +2. `arma/client/addons/common/ui/src/runtime.js` +3. `arma/client/addons/common/ui/src/bridge.js` +4. `arma/client/addons/common/ui/src/app.js` +5. `arma/client/addons/common/functions/fnc_initWebUIBridge.sqf` + +Those five pieces establish the core contract. After that, `org` and `store` can be migrated with low risk. + +## Non-Goals + +At least initially, this framework should not try to provide: + +- client-side routing between pages +- SSR or pre-rendering +- JSX compilation +- TypeScript-only tooling assumptions +- a giant component system +- generalized diffing for every possible DOM edge case + +This should stay focused on Arma in-browser application UIs. + +## Recommended Direction + +Use `forge_client_common` as the host for a small custom reactive framework, not as a dumping ground for copied app utilities. + +The correct abstraction boundary is: + +- `common` owns the browser platform +- each addon owns the application + +That gives one UI system across the repo without forcing all screens into one monolithic app. diff --git a/arma/client/addons/common/XEH_PREP.hpp b/arma/client/addons/common/XEH_PREP.hpp index 8b13789..ae721c8 100644 --- a/arma/client/addons/common/XEH_PREP.hpp +++ b/arma/client/addons/common/XEH_PREP.hpp @@ -1 +1,2 @@ +PREP(initWebUIBridge); diff --git a/arma/client/addons/common/functions/fnc_initWebUIBridge.sqf b/arma/client/addons/common/functions/fnc_initWebUIBridge.sqf new file mode 100644 index 0000000..15450d6 --- /dev/null +++ b/arma/client/addons/common/functions/fnc_initWebUIBridge.sqf @@ -0,0 +1,209 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initWebUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-13 + * Last Update: 2026-03-13 + * Public: No + * + * Description: + * Initializes the shared web UI bridge and screen declarations used by + * CT_WEBBROWSER feature bridges. + * + * Arguments: + * None + * + * Return Value: + * Web UI bridge declarations [HASHMAP] + * + * Example: + * call forge_client_common_fnc_initWebUIBridge + */ + +if !(isNil QGVAR(WebUIScreenDeclaration) || { isNil QGVAR(WebUIBridgeDeclaration) }) exitWith { + createHashMapFromArray [ + ["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)], + ["screenDeclaration", GVAR(WebUIScreenDeclaration)] + ] +}; + +#pragma hemtt ignore_variables ["_self"] +GVAR(WebUIScreenDeclaration) = compileFinal createHashMapFromArray [ + ["#type", "IWebUIScreen"], + ["#create", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + _self set ["control", _control]; + _self set ["readyState", false]; + _self set ["pendingEvents", []]; + }], + ["dispose", compileFinal { + _self set ["control", controlNull]; + _self set ["readyState", false]; + _self set ["pendingEvents", []]; + + true + }], + ["getControl", compileFinal { + _self getOrDefault ["control", controlNull] + }], + ["consumePendingEvents", compileFinal { + private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]); + _self set ["pendingEvents", []]; + + _pendingEvents + }], + ["isReady", compileFinal { + _self getOrDefault ["readyState", false] + }], + ["markReady", compileFinal { + params [["_isReady", true, [false]]]; + + _self set ["readyState", _isReady]; + _isReady + }], + ["queueEvent", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]); + _pendingEvents pushBack _payload; + _self set ["pendingEvents", _pendingEvents]; + + count _pendingEvents + }], + ["setControl", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + _self set ["control", _control]; + _control + }], + ["#delete", compileFinal { + _self call ["dispose", []]; + }] +]; + +GVAR(WebUIBridgeDeclaration) = compileFinal createHashMapFromArray [ + ["#type", "IWebUIBridge"], + ["#create", compileFinal { + _self set ["screen", createHashMapObject [GVAR(WebUIScreenDeclaration)]]; + }], + ["deliverPayload", compileFinal { + params [["_control", controlNull, [controlNull]], ["_payload", createHashMap, [createHashMap]]]; + + if (isNull _control) exitWith { false }; + + private _json = toJSON _payload; + _control ctrlWebBrowserAction ["ExecJS", format ["ForgeBridge.receive(%1)", _json]]; + + true + }], + ["execJS", compileFinal { + params [["_control", controlNull, [controlNull]], ["_statement", "", [""]]]; + + if (isNull _control || { _statement isEqualTo "" }) exitWith { false }; + + _control ctrlWebBrowserAction ["ExecJS", _statement]; + true + }], + ["flushPendingEvents", compileFinal { + private _screen = _self call ["getScreen", []]; + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { 0 }; + + private _pendingEvents = _screen call ["consumePendingEvents", []]; + + { + _self call ["deliverPayload", [_control, _x]]; + } forEach _pendingEvents; + + count _pendingEvents + }], + ["getActiveBrowserControl", compileFinal { + private _screen = _self call ["getScreen", []]; + _screen call ["getControl", []] + }], + ["getScreen", compileFinal { + private _hasScreen = "screen" in _self; + private _screen = if (_hasScreen) then { + _self get "screen" + } else { + createHashMap + }; + + if (!_hasScreen) then { + _screen = createHashMapObject [GVAR(WebUIScreenDeclaration)]; + _self set ["screen", _screen]; + }; + + _screen + }], + ["handleClose", compileFinal { + private _screen = _self call ["getScreen", []]; + _screen call ["dispose", []] + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]]; + _screen call ["markReady", [true]]; + + _self call ["flushPendingEvents", []]; + true + }], + ["queueEvent", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["queueEvent", [_payload]] + }], + ["sendEvent", compileFinal { + params [ + ["_event", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_control", controlNull, [controlNull]] + ]; + + if (_event isEqualTo "") exitWith { false }; + + private _payload = createHashMapFromArray [ + ["event", _event], + ["data", _data] + ]; + private _screen = _self call ["getScreen", []]; + private _targetControl = _control; + + if (isNull _targetControl) then { + _targetControl = _self call ["getActiveBrowserControl", []]; + }; + + if (isNull _targetControl) exitWith { + _self call ["queueEvent", [_payload]]; + false + }; + + _screen call ["setControl", [_targetControl]]; + + if !(_screen call ["isReady", []]) exitWith { + _self call ["queueEvent", [_payload]]; + false + }; + + _self call ["deliverPayload", [_targetControl, _payload]] + }], + ["setActiveBrowserControl", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]] + }], + ["#delete", compileFinal { + _self call ["handleClose", []]; + }] +]; + +createHashMapFromArray [ + ["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)], + ["screenDeclaration", GVAR(WebUIScreenDeclaration)] +] diff --git a/arma/client/addons/common/ui/_site/forge-site-loader.js b/arma/client/addons/common/ui/_site/forge-site-loader.js new file mode 100644 index 0000000..8590bd8 --- /dev/null +++ b/arma/client/addons/common/ui/_site/forge-site-loader.js @@ -0,0 +1,127 @@ +/* Generated by tools/build-webui.mjs for Forge Web UI site loader. Do not edit directly. */ +(function (global) { + const ForgeSiteLoader = (global.ForgeSiteLoader = + global.ForgeSiteLoader || {}); + const commonAddonRoot = "forge\\forge_client\\addons\\common\\ui\\_site\\"; + const defaultBrowserCommonBase = "../../../common/ui/_site/"; + + function isArmaAvailable() { + return ( + typeof A3API !== "undefined" && + A3API && + typeof A3API.RequestFile === "function" + ); + } + + function isAbsoluteAddonPath(path) { + return typeof path === "string" && path.startsWith("forge\\"); + } + + function normalizeAddonRoot(addonName) { + return `forge\\forge_client\\addons\\${addonName}\\ui\\_site\\`; + } + + function normalizeBrowserPath(basePath, assetPath) { + const normalizedBase = String(basePath || "./").replace(/\\/g, "/"); + const normalizedAssetPath = String(assetPath || "").replace(/\\/g, "/"); + return `${normalizedBase}${normalizedAssetPath}`; + } + + function requestText({ addonRoot, browserBase, assetPath }) { + if (isArmaAvailable()) { + const resolvedPath = isAbsoluteAddonPath(assetPath) + ? assetPath + : addonRoot + String(assetPath || "").replace(/\//g, "\\"); + return A3API.RequestFile(resolvedPath); + } + + const browserPath = isAbsoluteAddonPath(assetPath) + ? assetPath + : normalizeBrowserPath(browserBase, assetPath); + + return fetch(browserPath).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${browserPath}`); + } + + return response.text(); + }); + } + + function appendStyle(cssText) { + const style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + } + + function appendScript(jsText) { + const script = document.createElement("script"); + script.text = jsText; + document.head.appendChild(script); + } + + async function boot(config) { + const addonName = config && config.addonName ? config.addonName : ""; + + if (!addonName) { + throw new Error( + "ForgeSiteLoader requires a config.addonName value.", + ); + } + + const addonRoot = normalizeAddonRoot(addonName); + const browserAddonBase = config.browserAddonBase || "./"; + const browserCommonBase = + config.browserCommonBase || defaultBrowserCommonBase; + const styles = Array.isArray(config.styles) ? config.styles : []; + const commonScripts = Array.isArray(config.commonScripts) + ? config.commonScripts + : []; + const scripts = Array.isArray(config.scripts) ? config.scripts : []; + + const styleChunks = await Promise.all( + styles.map((assetPath) => + requestText({ + addonRoot, + browserBase: browserAddonBase, + assetPath, + }), + ), + ); + styleChunks.forEach(appendStyle); + + const commonScriptChunks = await Promise.all( + commonScripts.map((assetPath) => + requestText({ + addonRoot: commonAddonRoot, + browserBase: browserCommonBase, + assetPath, + }), + ), + ); + commonScriptChunks.forEach(appendScript); + + const scriptChunks = await Promise.all( + scripts.map((assetPath) => + requestText({ + addonRoot, + browserBase: browserAddonBase, + assetPath, + }), + ), + ); + scriptChunks.forEach(appendScript); + } + + ForgeSiteLoader.boot = boot; + + if (global.ForgeSiteConfig && global.ForgeSiteConfig.autoBoot !== false) { + boot(global.ForgeSiteConfig).catch((error) => { + const logLabel = + global.ForgeSiteConfig.logLabel || + global.ForgeSiteConfig.addonName || + "Forge UI"; + console.error(`[${logLabel}] Failed to load site assets.`, error); + }); + } +})(window); diff --git a/arma/client/addons/common/ui/_site/forge-webui.js b/arma/client/addons/common/ui/_site/forge-webui.js new file mode 100644 index 0000000..1587b65 --- /dev/null +++ b/arma/client/addons/common/ui/_site/forge-webui.js @@ -0,0 +1,933 @@ +/* Generated by tools/build-webui.mjs for Forge Web UI runtime. Do not edit directly. */ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + const SVG_NS = "http://www.w3.org/2000/svg"; + const SVG_TAGS = new Set([ + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "g", + "defs", + "use", + "text", + "tspan", + "clipPath", + "mask", + ]); + + const injectedStyles = new Set(); + const scheduledObservers = new Set(); + let activeObserver = null; + let batchDepth = 0; + let flushQueued = false; + + function queueFlush() { + if (flushQueued || batchDepth > 0) { + return; + } + + flushQueued = true; + queueMicrotask(() => { + flushQueued = false; + flushObservers(); + }); + } + + function flushObservers() { + while (scheduledObservers.size > 0) { + const queue = Array.from(scheduledObservers); + scheduledObservers.clear(); + queue.forEach((observer) => runObserver(observer)); + } + } + + function cleanupObserver(observer) { + if (typeof observer.cleanup === "function") { + try { + observer.cleanup(); + } catch (error) { + console.error("[ForgeWebUI] Observer cleanup failed.", error); + } + } + + observer.cleanup = null; + observer.dependencies.forEach((dependency) => { + dependency.delete(observer); + }); + observer.dependencies.clear(); + } + + function runObserver(observer) { + if (!observer || observer.disposed) { + return; + } + + cleanupObserver(observer); + + const previousObserver = activeObserver; + activeObserver = observer; + + try { + const cleanup = observer.fn(); + if (typeof cleanup === "function") { + observer.cleanup = cleanup; + } + } catch (error) { + console.error("[ForgeWebUI] Observer execution failed.", error); + } finally { + activeObserver = previousObserver; + } + } + + function scheduleObserver(observer) { + if (!observer || observer.disposed) { + return; + } + + scheduledObservers.add(observer); + queueFlush(); + } + + function trackDependency(dependency) { + if (!activeObserver) { + return; + } + + dependency.add(activeObserver); + activeObserver.dependencies.add(dependency); + } + + function createSignalValue(initialValue) { + let value = initialValue; + const subscribers = new Set(); + + function read() { + trackDependency(subscribers); + return value; + } + + read.peek = () => value; + read.set = (nextValue) => { + const resolvedValue = + typeof nextValue === "function" ? nextValue(value) : nextValue; + + if (Object.is(resolvedValue, value)) { + return value; + } + + value = resolvedValue; + subscribers.forEach((observer) => scheduleObserver(observer)); + return value; + }; + read.update = (updater) => read.set(updater); + read.subscribe = (listener) => + effect(() => { + listener(read()); + }); + + return read; + } + + function createSignal(initialValue) { + const signal = createSignalValue(initialValue); + return [signal, signal.set]; + } + + function computed(factory) { + const valueSignal = createSignalValue(undefined); + let initialized = false; + + effect(() => { + const nextValue = factory(); + if (!initialized || !Object.is(nextValue, valueSignal.peek())) { + initialized = true; + valueSignal.set(nextValue); + } + }); + + return valueSignal; + } + + function effect(fn) { + const observer = { + cleanup: null, + dependencies: new Set(), + disposed: false, + fn, + }; + + observer.dispose = () => { + if (observer.disposed) { + return; + } + + observer.disposed = true; + scheduledObservers.delete(observer); + cleanupObserver(observer); + }; + + runObserver(observer); + return observer.dispose; + } + + function batch(fn) { + batchDepth += 1; + + try { + return fn(); + } finally { + batchDepth = Math.max(0, batchDepth - 1); + if (batchDepth === 0) { + flushObservers(); + } + } + } + + function appendChild(node, child) { + if (child === null || child === undefined || child === false) { + return; + } + + if (Array.isArray(child)) { + child.forEach((entry) => appendChild(node, entry)); + return; + } + + if ( + typeof child === "string" || + typeof child === "number" || + typeof child === "bigint" + ) { + node.appendChild(document.createTextNode(String(child))); + return; + } + + if (child instanceof Node) { + node.appendChild(child); + } + } + + function fragment(...children) { + const node = document.createDocumentFragment(); + children.forEach((child) => appendChild(node, child)); + return node; + } + + function text(value) { + return document.createTextNode(String(value ?? "")); + } + + function applyProp(node, key, value, isSvg) { + if (key === "key") { + return; + } + + if (key === "ref" && typeof value === "function") { + value(node); + return; + } + + if (key === "className") { + if (isSvg) { + node.setAttribute("class", value || ""); + } else { + node.className = value || ""; + } + return; + } + + if (key === "style" && value && typeof value === "object") { + Object.assign(node.style, value); + return; + } + + if (key === "dataset" && value && typeof value === "object") { + Object.entries(value).forEach(([name, datasetValue]) => { + node.dataset[name] = datasetValue; + }); + return; + } + + if (key.startsWith("on") && typeof value === "function") { + node.addEventListener(key.slice(2).toLowerCase(), value); + return; + } + + if (key === "value" && "value" in node) { + node.value = value ?? ""; + return; + } + + if (key === "checked" && "checked" in node) { + node.checked = Boolean(value); + return; + } + + if (key === "selected" && "selected" in node) { + node.selected = Boolean(value); + return; + } + + if (typeof value === "boolean") { + if (value) { + node.setAttribute(key, ""); + } else { + node.removeAttribute(key); + } + return; + } + + if (value === null || value === undefined) { + node.removeAttribute(key); + return; + } + + node.setAttribute(key, value); + } + + function h(tag, props = {}, ...children) { + const isSvg = SVG_TAGS.has(tag); + const node = isSvg + ? document.createElementNS(SVG_NS, tag) + : document.createElement(tag); + + if (props && typeof props === "object") { + Object.entries(props).forEach(([key, value]) => { + applyProp(node, key, value, isSvg); + }); + } + + children.forEach((child) => appendChild(node, child)); + return node; + } + + function normalizeNode(node) { + if (node === null || node === undefined || node === false) { + return document.createDocumentFragment(); + } + + if (Array.isArray(node)) { + return fragment(...node); + } + + if ( + typeof node === "string" || + typeof node === "number" || + typeof node === "bigint" + ) { + return text(node); + } + + if (node instanceof Node) { + return node; + } + + return document.createDocumentFragment(); + } + + function captureScrollState(container) { + return Array.from( + container.querySelectorAll("[data-preserve-scroll-id]"), + ).map((node) => ({ + id: node.getAttribute("data-preserve-scroll-id"), + scrollLeft: node.scrollLeft, + scrollTop: node.scrollTop, + })); + } + + function restoreScrollState(container, scrollState) { + if (!Array.isArray(scrollState) || scrollState.length === 0) { + return; + } + + scrollState.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 mount(container, render, options = {}) { + const preserveScroll = options.preserveScroll !== false; + + const dispose = effect(() => { + const scrollState = preserveScroll + ? captureScrollState(container) + : []; + const nextNode = normalizeNode(render()); + + container.replaceChildren(nextNode); + + if (preserveScroll && scrollState.length > 0) { + requestAnimationFrame(() => { + restoreScrollState(container, scrollState); + }); + } + }); + + return { + container, + dispose, + rerender() { + container.replaceChildren(normalizeNode(render())); + }, + }; + } + + function render(component, container, options = {}) { + return mount(container, component, options); + } + + function unmount(mountHandle) { + if (!mountHandle || typeof mountHandle.dispose !== "function") { + return; + } + + mountHandle.dispose(); + } + + function ensureScopedStyle(id, cssText) { + if (!id || !cssText || injectedStyles.has(id)) { + return; + } + + const style = document.createElement("style"); + style.setAttribute("data-ui-style", id); + style.textContent = cssText; + document.head.appendChild(style); + injectedStyles.add(id); + } + + ForgeWebUI.batch = batch; + ForgeWebUI.computed = computed; + ForgeWebUI.createSignal = createSignal; + ForgeWebUI.effect = effect; + ForgeWebUI.ensureScopedStyle = ensureScopedStyle; + ForgeWebUI.fragment = fragment; + ForgeWebUI.h = h; + ForgeWebUI.mount = mount; + ForgeWebUI.render = render; + ForgeWebUI.signal = createSignalValue; + ForgeWebUI.text = text; + ForgeWebUI.unmount = unmount; +})(window); + +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function createHost() { + const api = global.A3API; + + return { + isArma: Boolean(api), + close(event = "ui::close", data = {}) { + return this.send(event, data); + }, + exec(statement) { + if ( + !api || + typeof api.Exec !== "function" || + typeof statement !== "string" + ) { + return false; + } + + api.Exec(statement); + return true; + }, + requestFile(path) { + if (api && typeof api.RequestFile === "function") { + return api.RequestFile(path); + } + + return fetch(path).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${path}`); + } + + return response.text(); + }); + }, + requestTexture(path, size = 512) { + if (api && typeof api.RequestTexture === "function") { + return api.RequestTexture(path, size); + } + + return Promise.reject( + new Error("Texture requests are unavailable outside Arma."), + ); + }, + send(event, data = {}) { + if ( + !api || + typeof api.SendAlert !== "function" || + typeof event !== "string" || + event === "" + ) { + return false; + } + + api.SendAlert( + JSON.stringify({ + event, + data, + }), + ); + return true; + }, + }; + } + + ForgeWebUI.createHost = createHost; +})(window); + +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function createBridge(options = {}) { + const host = + options.host && typeof options.host === "object" + ? options.host + : ForgeWebUI.createHost(); + const globalName = options.globalName || "ForgeBridge"; + const readyEvent = options.readyEvent || "ui::ready"; + const closeEvent = options.closeEvent || "ui::close"; + const listeners = new Map(); + + function getListeners(eventName) { + if (!listeners.has(eventName)) { + listeners.set(eventName, new Set()); + } + + return listeners.get(eventName); + } + + function emit(eventName, payload) { + const eventListeners = listeners.get(eventName); + if (!eventListeners || eventListeners.size === 0) { + return; + } + + eventListeners.forEach((listener) => { + try { + listener(payload); + } catch (error) { + console.error( + `[ForgeWebUI] Bridge listener failed for ${eventName}.`, + error, + ); + } + }); + } + + function receive(eventOrPayload, data = {}) { + const eventName = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? String(eventOrPayload.event || "") + : String(eventOrPayload || ""); + const payload = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.data || {} + : data; + + if (!eventName) { + return false; + } + + emit(eventName, payload); + emit("*", { data: payload, event: eventName }); + return true; + } + + function receiveMany(events) { + if (!Array.isArray(events)) { + return false; + } + + events.forEach((payload) => receive(payload)); + return true; + } + + const globalBridge = { + ping() { + return true; + }, + receive, + receiveMany, + reset() { + listeners.clear(); + return true; + }, + }; + + const api = { + close(data = {}) { + return host.send(closeEvent, data); + }, + emit, + host, + installCompatibility(name) { + if (name) { + global[name] = globalBridge; + } + + return api; + }, + off(eventName, listener) { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return false; + } + + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + + return true; + }, + on(eventName, listener) { + getListeners(eventName).add(listener); + return () => api.off(eventName, listener); + }, + ready(data = { loaded: true }) { + return host.send(readyEvent, data); + }, + receive, + receiveMany, + request(eventName, payload = {}) { + return host.send(eventName, payload); + }, + send(eventName, payload = {}) { + return host.send(eventName, payload); + }, + }; + + global[globalName] = globalBridge; + return api; + } + + ForgeWebUI.createBridge = createBridge; +})(window); + +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function resolveRoot(root) { + if (!root) { + return null; + } + + if (typeof root === "string") { + return document.querySelector(root); + } + + return root instanceof Element ? root : null; + } + + function createApp(options = {}) { + const name = options.name || "app"; + const root = options.root || "#app"; + const setup = + typeof options.setup === "function" ? options.setup : () => {}; + let started = false; + + function start() { + if (started) { + return; + } + + started = true; + + const boot = () => { + const rootNode = resolveRoot(root); + if (!rootNode) { + console.error( + `[ForgeWebUI] Root node not found for ${name}.`, + ); + return; + } + + setup({ + name, + root: rootNode, + runtime: ForgeWebUI, + }); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot, { + once: true, + }); + return; + } + + boot(); + } + + return { start }; + } + + ForgeWebUI.createApp = createApp; +})(window); + +(function (global) { + const ForgeWebUI = global.ForgeWebUI; + const SharedUI = (global.SharedUI = global.SharedUI || {}); + const { h, ensureScopedStyle } = ForgeWebUI; + const titleBarCss = ` +.ui-window-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: var(--ui-titlebar-min-height, 3.5rem); + padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem); + background: var( + --ui-titlebar-bg, + linear-gradient(180deg, #12325b 0%, #0d2643 100%) + ); + color: var(--ui-titlebar-text, #f4f8fd); + border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1)); + box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18)); + position: var(--ui-titlebar-position, relative); + top: var(--ui-titlebar-top, auto); + z-index: var(--ui-titlebar-z-index, 5); + flex-shrink: 0; +} + +.ui-window-titlebar-brand { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.1rem; + min-width: 0; +} + +.ui-window-titlebar-kicker { + font-size: 0.64rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72)); +} + +.ui-window-titlebar-title { + font-size: var(--ui-titlebar-title-size, 1rem); + font-weight: 700; + line-height: 1.1; + letter-spacing: var(--ui-titlebar-title-spacing, -0.03em); + color: inherit; +} + +.ui-window-titlebar-controls { + display: flex; + align-items: center; + gap: 0.12rem; +} + +.ui-window-control-btn { + min-width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border-radius: 0.38rem; + border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16)); + background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04)); + color: var(--ui-window-control-text, rgb(237 244 251 / 0.88)); + line-height: 1; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + box-shadow: none; + transform: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ui-window-control-btn + .ui-window-control-btn { + margin-left: 0; +} + +.ui-window-control-btn:hover { + background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04)); + box-shadow: none; + transform: none; +} + +.ui-window-control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ui-window-control-btn.is-close { + cursor: pointer; + opacity: 1; + background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1)); +} + +.ui-window-control-btn.is-close:hover { + background: var( + --ui-window-control-close-hover-bg, + rgb(185 67 67 / 0.9) + ); + border-color: var( + --ui-window-control-close-hover-border, + rgb(255 222 222 / 0.45) + ); +} + +.ui-window-control-icon { + width: 0.78rem; + height: 0.78rem; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +@media (max-width: 960px) { + .ui-window-titlebar { + flex-direction: column; + align-items: flex-start; + } + + .ui-window-titlebar-controls { + width: 100%; + justify-content: flex-end; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + function WindowControlIcon({ type }) { + if (type === "minimize") { + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("line", { x1: "3", y1: "8", x2: "13", y2: "8" }), + ); + } + + if (type === "maximize") { + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("rect", { x: "3.5", y: "3.5", width: "9", height: "9" }), + ); + } + + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("line", { x1: "4", y1: "4", x2: "12", y2: "12" }), + h("line", { x1: "12", y1: "4", x2: "4", y2: "12" }), + ); + } + + SharedUI.componentFns.WindowTitleBar = function WindowTitleBar({ + kicker = "", + title = "", + onClose = null, + closeLabel = "Close interface", + minimizeLabel = "Minimize unavailable", + maximizeLabel = "Maximize unavailable", + } = {}) { + ensureScopedStyle("shared-window-titlebar", titleBarCss); + + return h( + "div", + { className: "ui-window-titlebar" }, + h( + "div", + { className: "ui-window-titlebar-brand" }, + kicker + ? h( + "span", + { className: "ui-window-titlebar-kicker" }, + kicker, + ) + : null, + h("span", { className: "ui-window-titlebar-title" }, title), + ), + h( + "div", + { className: "ui-window-titlebar-controls" }, + h( + "button", + { + type: "button", + className: "ui-window-control-btn", + disabled: true, + title: minimizeLabel, + "aria-label": minimizeLabel, + }, + WindowControlIcon({ type: "minimize" }), + ), + h( + "button", + { + type: "button", + className: "ui-window-control-btn", + disabled: true, + title: maximizeLabel, + "aria-label": maximizeLabel, + }, + WindowControlIcon({ type: "maximize" }), + ), + h( + "button", + { + type: "button", + className: "ui-window-control-btn is-close", + title: "Close", + "aria-label": closeLabel, + onClick: + typeof onClose === "function" ? onClose : () => {}, + }, + WindowControlIcon({ type: "close" }), + ), + ), + ); + }; +})(window); + +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + ForgeWebUI.version = "0.1.0"; +})(window); diff --git a/arma/client/addons/common/ui/src/app.js b/arma/client/addons/common/ui/src/app.js new file mode 100644 index 0000000..a3ad096 --- /dev/null +++ b/arma/client/addons/common/ui/src/app.js @@ -0,0 +1,60 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function resolveRoot(root) { + if (!root) { + return null; + } + + if (typeof root === "string") { + return document.querySelector(root); + } + + return root instanceof Element ? root : null; + } + + function createApp(options = {}) { + const name = options.name || "app"; + const root = options.root || "#app"; + const setup = + typeof options.setup === "function" ? options.setup : () => {}; + let started = false; + + function start() { + if (started) { + return; + } + + started = true; + + const boot = () => { + const rootNode = resolveRoot(root); + if (!rootNode) { + console.error( + `[ForgeWebUI] Root node not found for ${name}.`, + ); + return; + } + + setup({ + name, + root: rootNode, + runtime: ForgeWebUI, + }); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot, { + once: true, + }); + return; + } + + boot(); + } + + return { start }; + } + + ForgeWebUI.createApp = createApp; +})(window); diff --git a/arma/client/addons/common/ui/src/bridge.js b/arma/client/addons/common/ui/src/bridge.js new file mode 100644 index 0000000..877f393 --- /dev/null +++ b/arma/client/addons/common/ui/src/bridge.js @@ -0,0 +1,128 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function createBridge(options = {}) { + const host = + options.host && typeof options.host === "object" + ? options.host + : ForgeWebUI.createHost(); + const globalName = options.globalName || "ForgeBridge"; + const readyEvent = options.readyEvent || "ui::ready"; + const closeEvent = options.closeEvent || "ui::close"; + const listeners = new Map(); + + function getListeners(eventName) { + if (!listeners.has(eventName)) { + listeners.set(eventName, new Set()); + } + + return listeners.get(eventName); + } + + function emit(eventName, payload) { + const eventListeners = listeners.get(eventName); + if (!eventListeners || eventListeners.size === 0) { + return; + } + + eventListeners.forEach((listener) => { + try { + listener(payload); + } catch (error) { + console.error( + `[ForgeWebUI] Bridge listener failed for ${eventName}.`, + error, + ); + } + }); + } + + function receive(eventOrPayload, data = {}) { + const eventName = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? String(eventOrPayload.event || "") + : String(eventOrPayload || ""); + const payload = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.data || {} + : data; + + if (!eventName) { + return false; + } + + emit(eventName, payload); + emit("*", { data: payload, event: eventName }); + return true; + } + + function receiveMany(events) { + if (!Array.isArray(events)) { + return false; + } + + events.forEach((payload) => receive(payload)); + return true; + } + + const globalBridge = { + ping() { + return true; + }, + receive, + receiveMany, + reset() { + listeners.clear(); + return true; + }, + }; + + const api = { + close(data = {}) { + return host.send(closeEvent, data); + }, + emit, + host, + installCompatibility(name) { + if (name) { + global[name] = globalBridge; + } + + return api; + }, + off(eventName, listener) { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return false; + } + + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + + return true; + }, + on(eventName, listener) { + getListeners(eventName).add(listener); + return () => api.off(eventName, listener); + }, + ready(data = { loaded: true }) { + return host.send(readyEvent, data); + }, + receive, + receiveMany, + request(eventName, payload = {}) { + return host.send(eventName, payload); + }, + send(eventName, payload = {}) { + return host.send(eventName, payload); + }, + }; + + global[globalName] = globalBridge; + return api; + } + + ForgeWebUI.createBridge = createBridge; +})(window); diff --git a/arma/client/addons/common/ui/src/host.js b/arma/client/addons/common/ui/src/host.js new file mode 100644 index 0000000..8c199fd --- /dev/null +++ b/arma/client/addons/common/ui/src/host.js @@ -0,0 +1,68 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function createHost() { + const api = global.A3API; + + return { + isArma: Boolean(api), + close(event = "ui::close", data = {}) { + return this.send(event, data); + }, + exec(statement) { + if ( + !api || + typeof api.Exec !== "function" || + typeof statement !== "string" + ) { + return false; + } + + api.Exec(statement); + return true; + }, + requestFile(path) { + if (api && typeof api.RequestFile === "function") { + return api.RequestFile(path); + } + + return fetch(path).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${path}`); + } + + return response.text(); + }); + }, + requestTexture(path, size = 512) { + if (api && typeof api.RequestTexture === "function") { + return api.RequestTexture(path, size); + } + + return Promise.reject( + new Error("Texture requests are unavailable outside Arma."), + ); + }, + send(event, data = {}) { + if ( + !api || + typeof api.SendAlert !== "function" || + typeof event !== "string" || + event === "" + ) { + return false; + } + + api.SendAlert( + JSON.stringify({ + event, + data, + }), + ); + return true; + }, + }; + } + + ForgeWebUI.createHost = createHost; +})(window); diff --git a/arma/client/addons/common/ui/src/index.js b/arma/client/addons/common/ui/src/index.js new file mode 100644 index 0000000..ef53b2e --- /dev/null +++ b/arma/client/addons/common/ui/src/index.js @@ -0,0 +1,5 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + ForgeWebUI.version = "0.1.0"; +})(window); diff --git a/arma/client/addons/common/ui/src/runtime.js b/arma/client/addons/common/ui/src/runtime.js new file mode 100644 index 0000000..aa453b3 --- /dev/null +++ b/arma/client/addons/common/ui/src/runtime.js @@ -0,0 +1,428 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + const SVG_NS = "http://www.w3.org/2000/svg"; + const SVG_TAGS = new Set([ + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "g", + "defs", + "use", + "text", + "tspan", + "clipPath", + "mask", + ]); + + const injectedStyles = new Set(); + const scheduledObservers = new Set(); + let activeObserver = null; + let batchDepth = 0; + let flushQueued = false; + + function queueFlush() { + if (flushQueued || batchDepth > 0) { + return; + } + + flushQueued = true; + queueMicrotask(() => { + flushQueued = false; + flushObservers(); + }); + } + + function flushObservers() { + while (scheduledObservers.size > 0) { + const queue = Array.from(scheduledObservers); + scheduledObservers.clear(); + queue.forEach((observer) => runObserver(observer)); + } + } + + function cleanupObserver(observer) { + if (typeof observer.cleanup === "function") { + try { + observer.cleanup(); + } catch (error) { + console.error("[ForgeWebUI] Observer cleanup failed.", error); + } + } + + observer.cleanup = null; + observer.dependencies.forEach((dependency) => { + dependency.delete(observer); + }); + observer.dependencies.clear(); + } + + function runObserver(observer) { + if (!observer || observer.disposed) { + return; + } + + cleanupObserver(observer); + + const previousObserver = activeObserver; + activeObserver = observer; + + try { + const cleanup = observer.fn(); + if (typeof cleanup === "function") { + observer.cleanup = cleanup; + } + } catch (error) { + console.error("[ForgeWebUI] Observer execution failed.", error); + } finally { + activeObserver = previousObserver; + } + } + + function scheduleObserver(observer) { + if (!observer || observer.disposed) { + return; + } + + scheduledObservers.add(observer); + queueFlush(); + } + + function trackDependency(dependency) { + if (!activeObserver) { + return; + } + + dependency.add(activeObserver); + activeObserver.dependencies.add(dependency); + } + + function createSignalValue(initialValue) { + let value = initialValue; + const subscribers = new Set(); + + function read() { + trackDependency(subscribers); + return value; + } + + read.peek = () => value; + read.set = (nextValue) => { + const resolvedValue = + typeof nextValue === "function" ? nextValue(value) : nextValue; + + if (Object.is(resolvedValue, value)) { + return value; + } + + value = resolvedValue; + subscribers.forEach((observer) => scheduleObserver(observer)); + return value; + }; + read.update = (updater) => read.set(updater); + read.subscribe = (listener) => + effect(() => { + listener(read()); + }); + + return read; + } + + function createSignal(initialValue) { + const signal = createSignalValue(initialValue); + return [signal, signal.set]; + } + + function computed(factory) { + const valueSignal = createSignalValue(undefined); + let initialized = false; + + effect(() => { + const nextValue = factory(); + if (!initialized || !Object.is(nextValue, valueSignal.peek())) { + initialized = true; + valueSignal.set(nextValue); + } + }); + + return valueSignal; + } + + function effect(fn) { + const observer = { + cleanup: null, + dependencies: new Set(), + disposed: false, + fn, + }; + + observer.dispose = () => { + if (observer.disposed) { + return; + } + + observer.disposed = true; + scheduledObservers.delete(observer); + cleanupObserver(observer); + }; + + runObserver(observer); + return observer.dispose; + } + + function batch(fn) { + batchDepth += 1; + + try { + return fn(); + } finally { + batchDepth = Math.max(0, batchDepth - 1); + if (batchDepth === 0) { + flushObservers(); + } + } + } + + function appendChild(node, child) { + if (child === null || child === undefined || child === false) { + return; + } + + if (Array.isArray(child)) { + child.forEach((entry) => appendChild(node, entry)); + return; + } + + if ( + typeof child === "string" || + typeof child === "number" || + typeof child === "bigint" + ) { + node.appendChild(document.createTextNode(String(child))); + return; + } + + if (child instanceof Node) { + node.appendChild(child); + } + } + + function fragment(...children) { + const node = document.createDocumentFragment(); + children.forEach((child) => appendChild(node, child)); + return node; + } + + function text(value) { + return document.createTextNode(String(value ?? "")); + } + + function applyProp(node, key, value, isSvg) { + if (key === "key") { + return; + } + + if (key === "ref" && typeof value === "function") { + value(node); + return; + } + + if (key === "className") { + if (isSvg) { + node.setAttribute("class", value || ""); + } else { + node.className = value || ""; + } + return; + } + + if (key === "style" && value && typeof value === "object") { + Object.assign(node.style, value); + return; + } + + if (key === "dataset" && value && typeof value === "object") { + Object.entries(value).forEach(([name, datasetValue]) => { + node.dataset[name] = datasetValue; + }); + return; + } + + if (key.startsWith("on") && typeof value === "function") { + node.addEventListener(key.slice(2).toLowerCase(), value); + return; + } + + if (key === "value" && "value" in node) { + node.value = value ?? ""; + return; + } + + if (key === "checked" && "checked" in node) { + node.checked = Boolean(value); + return; + } + + if (key === "selected" && "selected" in node) { + node.selected = Boolean(value); + return; + } + + if (typeof value === "boolean") { + if (value) { + node.setAttribute(key, ""); + } else { + node.removeAttribute(key); + } + return; + } + + if (value === null || value === undefined) { + node.removeAttribute(key); + return; + } + + node.setAttribute(key, value); + } + + function h(tag, props = {}, ...children) { + const isSvg = SVG_TAGS.has(tag); + const node = isSvg + ? document.createElementNS(SVG_NS, tag) + : document.createElement(tag); + + if (props && typeof props === "object") { + Object.entries(props).forEach(([key, value]) => { + applyProp(node, key, value, isSvg); + }); + } + + children.forEach((child) => appendChild(node, child)); + return node; + } + + function normalizeNode(node) { + if (node === null || node === undefined || node === false) { + return document.createDocumentFragment(); + } + + if (Array.isArray(node)) { + return fragment(...node); + } + + if ( + typeof node === "string" || + typeof node === "number" || + typeof node === "bigint" + ) { + return text(node); + } + + if (node instanceof Node) { + return node; + } + + return document.createDocumentFragment(); + } + + function captureScrollState(container) { + return Array.from( + container.querySelectorAll("[data-preserve-scroll-id]"), + ).map((node) => ({ + id: node.getAttribute("data-preserve-scroll-id"), + scrollLeft: node.scrollLeft, + scrollTop: node.scrollTop, + })); + } + + function restoreScrollState(container, scrollState) { + if (!Array.isArray(scrollState) || scrollState.length === 0) { + return; + } + + scrollState.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 mount(container, render, options = {}) { + const preserveScroll = options.preserveScroll !== false; + + const dispose = effect(() => { + const scrollState = preserveScroll + ? captureScrollState(container) + : []; + const nextNode = normalizeNode(render()); + + container.replaceChildren(nextNode); + + if (preserveScroll && scrollState.length > 0) { + requestAnimationFrame(() => { + restoreScrollState(container, scrollState); + }); + } + }); + + return { + container, + dispose, + rerender() { + container.replaceChildren(normalizeNode(render())); + }, + }; + } + + function render(component, container, options = {}) { + return mount(container, component, options); + } + + function unmount(mountHandle) { + if (!mountHandle || typeof mountHandle.dispose !== "function") { + return; + } + + mountHandle.dispose(); + } + + function ensureScopedStyle(id, cssText) { + if (!id || !cssText || injectedStyles.has(id)) { + return; + } + + const style = document.createElement("style"); + style.setAttribute("data-ui-style", id); + style.textContent = cssText; + document.head.appendChild(style); + injectedStyles.add(id); + } + + ForgeWebUI.batch = batch; + ForgeWebUI.computed = computed; + ForgeWebUI.createSignal = createSignal; + ForgeWebUI.effect = effect; + ForgeWebUI.ensureScopedStyle = ensureScopedStyle; + ForgeWebUI.fragment = fragment; + ForgeWebUI.h = h; + ForgeWebUI.mount = mount; + ForgeWebUI.render = render; + ForgeWebUI.signal = createSignalValue; + ForgeWebUI.text = text; + ForgeWebUI.unmount = unmount; +})(window); diff --git a/arma/client/addons/common/ui/src/siteLoader.js b/arma/client/addons/common/ui/src/siteLoader.js new file mode 100644 index 0000000..a5a6600 --- /dev/null +++ b/arma/client/addons/common/ui/src/siteLoader.js @@ -0,0 +1,126 @@ +(function (global) { + const ForgeSiteLoader = (global.ForgeSiteLoader = + global.ForgeSiteLoader || {}); + const commonAddonRoot = "forge\\forge_client\\addons\\common\\ui\\_site\\"; + const defaultBrowserCommonBase = "../../../common/ui/_site/"; + + function isArmaAvailable() { + return ( + typeof A3API !== "undefined" && + A3API && + typeof A3API.RequestFile === "function" + ); + } + + function isAbsoluteAddonPath(path) { + return typeof path === "string" && path.startsWith("forge\\"); + } + + function normalizeAddonRoot(addonName) { + return `forge\\forge_client\\addons\\${addonName}\\ui\\_site\\`; + } + + function normalizeBrowserPath(basePath, assetPath) { + const normalizedBase = String(basePath || "./").replace(/\\/g, "/"); + const normalizedAssetPath = String(assetPath || "").replace(/\\/g, "/"); + return `${normalizedBase}${normalizedAssetPath}`; + } + + function requestText({ addonRoot, browserBase, assetPath }) { + if (isArmaAvailable()) { + const resolvedPath = isAbsoluteAddonPath(assetPath) + ? assetPath + : addonRoot + String(assetPath || "").replace(/\//g, "\\"); + return A3API.RequestFile(resolvedPath); + } + + const browserPath = isAbsoluteAddonPath(assetPath) + ? assetPath + : normalizeBrowserPath(browserBase, assetPath); + + return fetch(browserPath).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${browserPath}`); + } + + return response.text(); + }); + } + + function appendStyle(cssText) { + const style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + } + + function appendScript(jsText) { + const script = document.createElement("script"); + script.text = jsText; + document.head.appendChild(script); + } + + async function boot(config) { + const addonName = config && config.addonName ? config.addonName : ""; + + if (!addonName) { + throw new Error( + "ForgeSiteLoader requires a config.addonName value.", + ); + } + + const addonRoot = normalizeAddonRoot(addonName); + const browserAddonBase = config.browserAddonBase || "./"; + const browserCommonBase = + config.browserCommonBase || defaultBrowserCommonBase; + const styles = Array.isArray(config.styles) ? config.styles : []; + const commonScripts = Array.isArray(config.commonScripts) + ? config.commonScripts + : []; + const scripts = Array.isArray(config.scripts) ? config.scripts : []; + + const styleChunks = await Promise.all( + styles.map((assetPath) => + requestText({ + addonRoot, + browserBase: browserAddonBase, + assetPath, + }), + ), + ); + styleChunks.forEach(appendStyle); + + const commonScriptChunks = await Promise.all( + commonScripts.map((assetPath) => + requestText({ + addonRoot: commonAddonRoot, + browserBase: browserCommonBase, + assetPath, + }), + ), + ); + commonScriptChunks.forEach(appendScript); + + const scriptChunks = await Promise.all( + scripts.map((assetPath) => + requestText({ + addonRoot, + browserBase: browserAddonBase, + assetPath, + }), + ), + ); + scriptChunks.forEach(appendScript); + } + + ForgeSiteLoader.boot = boot; + + if (global.ForgeSiteConfig && global.ForgeSiteConfig.autoBoot !== false) { + boot(global.ForgeSiteConfig).catch((error) => { + const logLabel = + global.ForgeSiteConfig.logLabel || + global.ForgeSiteConfig.addonName || + "Forge UI"; + console.error(`[${logLabel}] Failed to load site assets.`, error); + }); + } +})(window); diff --git a/arma/client/addons/common/ui/src/windowTitleBar.js b/arma/client/addons/common/ui/src/windowTitleBar.js new file mode 100644 index 0000000..fe8ea58 --- /dev/null +++ b/arma/client/addons/common/ui/src/windowTitleBar.js @@ -0,0 +1,238 @@ +(function (global) { + const ForgeWebUI = global.ForgeWebUI; + const SharedUI = (global.SharedUI = global.SharedUI || {}); + const { h, ensureScopedStyle } = ForgeWebUI; + const titleBarCss = ` +.ui-window-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: var(--ui-titlebar-min-height, 3.5rem); + padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem); + background: var( + --ui-titlebar-bg, + linear-gradient(180deg, #12325b 0%, #0d2643 100%) + ); + color: var(--ui-titlebar-text, #f4f8fd); + border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1)); + box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18)); + position: var(--ui-titlebar-position, relative); + top: var(--ui-titlebar-top, auto); + z-index: var(--ui-titlebar-z-index, 5); + flex-shrink: 0; +} + +.ui-window-titlebar-brand { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.1rem; + min-width: 0; +} + +.ui-window-titlebar-kicker { + font-size: 0.64rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72)); +} + +.ui-window-titlebar-title { + font-size: var(--ui-titlebar-title-size, 1rem); + font-weight: 700; + line-height: 1.1; + letter-spacing: var(--ui-titlebar-title-spacing, -0.03em); + color: inherit; +} + +.ui-window-titlebar-controls { + display: flex; + align-items: center; + gap: 0.12rem; +} + +.ui-window-control-btn { + min-width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border-radius: 0.38rem; + border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16)); + background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04)); + color: var(--ui-window-control-text, rgb(237 244 251 / 0.88)); + line-height: 1; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + box-shadow: none; + transform: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ui-window-control-btn + .ui-window-control-btn { + margin-left: 0; +} + +.ui-window-control-btn:hover { + background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04)); + box-shadow: none; + transform: none; +} + +.ui-window-control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ui-window-control-btn.is-close { + cursor: pointer; + opacity: 1; + background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1)); +} + +.ui-window-control-btn.is-close:hover { + background: var( + --ui-window-control-close-hover-bg, + rgb(185 67 67 / 0.9) + ); + border-color: var( + --ui-window-control-close-hover-border, + rgb(255 222 222 / 0.45) + ); +} + +.ui-window-control-icon { + width: 0.78rem; + height: 0.78rem; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +@media (max-width: 960px) { + .ui-window-titlebar { + flex-direction: column; + align-items: flex-start; + } + + .ui-window-titlebar-controls { + width: 100%; + justify-content: flex-end; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + function WindowControlIcon({ type }) { + if (type === "minimize") { + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("line", { x1: "3", y1: "8", x2: "13", y2: "8" }), + ); + } + + if (type === "maximize") { + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("rect", { x: "3.5", y: "3.5", width: "9", height: "9" }), + ); + } + + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("line", { x1: "4", y1: "4", x2: "12", y2: "12" }), + h("line", { x1: "12", y1: "4", x2: "4", y2: "12" }), + ); + } + + SharedUI.componentFns.WindowTitleBar = function WindowTitleBar({ + kicker = "", + title = "", + onClose = null, + closeLabel = "Close interface", + minimizeLabel = "Minimize unavailable", + maximizeLabel = "Maximize unavailable", + } = {}) { + ensureScopedStyle("shared-window-titlebar", titleBarCss); + + return h( + "div", + { className: "ui-window-titlebar" }, + h( + "div", + { className: "ui-window-titlebar-brand" }, + kicker + ? h( + "span", + { className: "ui-window-titlebar-kicker" }, + kicker, + ) + : null, + h("span", { className: "ui-window-titlebar-title" }, title), + ), + h( + "div", + { className: "ui-window-titlebar-controls" }, + h( + "button", + { + type: "button", + className: "ui-window-control-btn", + disabled: true, + title: minimizeLabel, + "aria-label": minimizeLabel, + }, + WindowControlIcon({ type: "minimize" }), + ), + h( + "button", + { + type: "button", + className: "ui-window-control-btn", + disabled: true, + title: maximizeLabel, + "aria-label": maximizeLabel, + }, + WindowControlIcon({ type: "maximize" }), + ), + h( + "button", + { + type: "button", + className: "ui-window-control-btn is-close", + title: "Close", + "aria-label": closeLabel, + onClick: + typeof onClose === "function" ? onClose : () => {}, + }, + WindowControlIcon({ type: "close" }), + ), + ), + ); + }; +})(window); diff --git a/arma/client/addons/org/config.cpp b/arma/client/addons/org/config.cpp index 2b01e51..3aca435 100644 --- a/arma/client/addons/org/config.cpp +++ b/arma/client/addons/org/config.cpp @@ -8,6 +8,7 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { + "forge_client_common", "forge_client_main" }; units[] = {}; diff --git a/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf b/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf index 36f7184..e402612 100644 --- a/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf +++ b/arma/client/addons/org/functions/fnc_initOrgUIBridge.sqf @@ -4,7 +4,7 @@ * File: fnc_initOrgUIBridge.sqf * Author: IDSolutions * Date: 2026-03-10 - * Last Update: 2026-03-12 + * Last Update: 2026-03-13 * Public: No * * Description: @@ -21,11 +21,12 @@ */ #pragma hemtt ignore_variables ["_self"] +private _webUIDeclarations = call EFUNC(common,initWebUIBridge); +private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; + GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#base", _webUIBridgeDeclaration], ["#type", "OrgUIBridgeBaseClass"], - ["#create", compileFinal { - _self set ["pendingBrowserControl", controlNull]; - }], ["setPendingBrowserControl", compileFinal { params [["_control", controlNull, [controlNull]]]; @@ -40,44 +41,14 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ }], ["getActiveBrowserControl", compileFinal { private _display = uiNamespace getVariable ["RscOrg", displayNull]; - if (isNull _display) exitWith { controlNull }; - - _display displayCtrl 1003 - }], - ["execBridge", compileFinal { - params [ - ["_control", controlNull, [controlNull]], - ["_fnName", "", [""]], - ["_payload", createHashMap, [createHashMap]] - ]; - - if (isNull _control || { _fnName isEqualTo "" }) exitWith { false }; - - private _json = toJSON _payload; - _control ctrlWebBrowserAction ["ExecJS", format ["OrgUIBridge.%1(%2)", _fnName, _json]]; - - true - }], - ["sendBridgeEvent", compileFinal { - 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 _display) exitWith { + _self call ["setActiveBrowserControl", [controlNull]]; + controlNull }; - if (isNull _targetControl) exitWith { false }; - - _self call ["execBridge", [_targetControl, "receive", createHashMapFromArray [ - ["event", _event], - ["data", _data] - ]]] + private _control = _display displayCtrl 1003; + _self call ["setActiveBrowserControl", [_control]]; + _control }], ["handleLoginRequest", compileFinal { params [["_control", controlNull, [controlNull]]]; @@ -87,24 +58,21 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ private _orgName = _orgData getOrDefault ["name", ""]; if (_orgId isEqualTo "" && { _orgName isEqualTo "" }) exitWith { - _self call ["execBridge", [_control, "receiveLoginFailure", createHashMapFromArray [ + _self call ["sendEvent", ["org::login::failure", createHashMapFromArray [ ["message", "No organization data is available for this player."] - ]]]; + ], _control]]; }; - _self call ["execBridge", [_control, "receiveLoginSuccess", GVAR(OrgClass) call ["buildPortalPayload", []]]]; + _self call ["sendEvent", ["org::login::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]; }], ["handleCreateRequest", compileFinal { - params [ - ["_control", controlNull, [controlNull]], - ["_data", createHashMap, [createHashMap]] - ]; + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; private _orgName = _data getOrDefault ["orgName", ""]; if (_orgName isEqualTo "") exitWith { - _self call ["execBridge", [_control, "receiveCreateFailure", createHashMapFromArray [ + _self call ["sendEvent", ["org::create::failure", createHashMapFromArray [ ["message", "Enter an organization name."] - ]]]; + ], _control]]; }; _self call ["setPendingBrowserControl", [_control]]; @@ -118,16 +86,16 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ if (!_success) exitWith { if (isNull _control) exitWith {}; - _self call ["execBridge", [_control, "receiveCreateFailure", createHashMapFromArray [ + _self call ["sendEvent", ["org::create::failure", createHashMapFromArray [ ["message", _payload getOrDefault ["message", "Organization registration failed."]] - ]]]; + ], _control]]; }; private _orgData = _payload getOrDefault ["org", createHashMap]; GVAR(OrgClass) call ["sync", [_orgData, true]]; if (isNull _control) exitWith {}; - _self call ["execBridge", [_control, "receiveCreateSuccess", GVAR(OrgClass) call ["buildPortalPayload", []]]]; + _self call ["sendEvent", ["org::create::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]; }], ["handleDisbandResponse", compileFinal { params [["_payload", createHashMap, [createHashMap]]]; @@ -138,7 +106,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ "org::disband::failure" }; - _self call ["sendBridgeEvent", [_eventName, _payload]]; + _self call ["sendEvent", [_eventName, _payload]]; }], ["handleLeaveResponse", compileFinal { params [["_payload", createHashMap, [createHashMap]]]; @@ -148,7 +116,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ "org::leave::success" ] select (_payload getOrDefault ["success", false]); - _self call ["sendBridgeEvent", [_eventName, _payload]]; + _self call ["sendEvent", [_eventName, _payload]]; }], ["handleCreditLineResponse", compileFinal { params [["_payload", createHashMap, [createHashMap]]]; @@ -158,7 +126,18 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ "org::credit::success" ] select (_payload getOrDefault ["success", false]); - _self call ["sendBridgeEvent", [_eventName, _payload]]; + _self call ["sendEvent", [_eventName, _payload]]; + + if (_payload getOrDefault ["success", false]) then { + private _memberUid = _payload getOrDefault ["memberUid", ""]; + if (_memberUid isNotEqualTo "") then { + _self call ["sendEvent", ["org::member::creditUpdated", createHashMapFromArray [ + ["amount", _payload getOrDefault ["amount", 0]], + ["memberName", _payload getOrDefault ["memberName", ""]], + ["memberUid", _memberUid] + ]]]; + }; + }; }], ["requestDisband", compileFinal { [SRPC(org,requestDisbandOrg), [getPlayerUID player]] call CFUNC(serverEvent); @@ -179,18 +158,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ private _control = _self call ["getActiveBrowserControl", []]; if (isNull _control) exitWith { false }; - _self call ["sendBridgeEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]] - }], - ["handleReady", compileFinal { - params [["_control", controlNull, [controlNull]]]; - - _self call ["sendBridgeEvent", [ - "org::ready", - createHashMapFromArray [ - ["loaded", true] - ], - _control - ]]; + _self call ["sendEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]] }] ]; diff --git a/arma/client/addons/org/ui/_site/bootstrap.js b/arma/client/addons/org/ui/_site/bootstrap.js deleted file mode 100644 index 663a0b3..0000000 --- a/arma/client/addons/org/ui/_site/bootstrap.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Registry app bootstrap - */ - -const root = document.getElementById("app"); -window.RegistryApp.runtime.render(window.RegistryApp.components.App, root); diff --git a/arma/client/addons/org/ui/_site/bridge.js b/arma/client/addons/org/ui/_site/bridge.js deleted file mode 100644 index 94626c6..0000000 --- a/arma/client/addons/org/ui/_site/bridge.js +++ /dev/null @@ -1,238 +0,0 @@ -(function () { - const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); - const store = RegistryApp.store; - - function sendEvent(event, data) { - if ( - typeof A3API !== "undefined" && - typeof A3API.SendAlert === "function" - ) { - A3API.SendAlert( - JSON.stringify({ - event, - data, - }), - ); - return true; - } - - return false; - } - - function requestLogin(credentials) { - store.startLogin(); - - const sent = sendEvent("org::login::request", credentials); - if (sent) { - return; - } - - store.failLogin("Arma login bridge is unavailable."); - } - - function requestCreateOrg(registration) { - store.startCreate(); - - const sent = sendEvent("org::create::request", registration); - if (sent) { - return; - } - - store.failCreate("Arma registration bridge is unavailable."); - } - - function requestDisbandOrg() { - const sent = sendEvent("org::disband::request", {}); - if (sent) { - return; - } - - const OrgPortal = window.OrgPortal; - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "error", - "Arma disband bridge is unavailable.", - ); - } - } - - function requestLeaveOrg() { - const sent = sendEvent("org::leave::request", {}); - if (sent) { - return; - } - - const OrgPortal = window.OrgPortal; - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "error", - "Arma leave bridge is unavailable.", - ); - } - } - - function requestCreditLine(payload) { - const sent = sendEvent("org::credit::request", payload); - if (sent) { - return true; - } - - const OrgPortal = window.OrgPortal; - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "error", - "Arma credit line bridge is unavailable.", - ); - } - - return false; - } - - function receive(eventOrPayload, data = {}) { - const event = - typeof eventOrPayload === "object" && eventOrPayload !== null - ? eventOrPayload.event - : eventOrPayload; - const payloadData = - typeof eventOrPayload === "object" && eventOrPayload !== null - ? eventOrPayload.data || {} - : data; - - if (event === "org::login::success") { - store.completeLogin(payloadData); - return; - } - - if (event === "org::login::failure") { - store.failLogin(payloadData.message || "Authentication failed."); - return; - } - - if (event === "org::create::success") { - store.completeCreate(payloadData); - return; - } - - if (event === "org::create::failure") { - store.failCreate( - payloadData.message || "Organization registration failed.", - ); - return; - } - - if (event === "org::sync") { - if (store && typeof store.hydratePortal === "function") { - store.hydratePortal(payloadData); - } - return; - } - - const OrgPortal = window.OrgPortal; - if (event === "org::credit::success") { - if (OrgPortal && OrgPortal.store) { - OrgPortal.store.setModal(null); - } - - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "success", - payloadData.message || "Credit line assigned.", - ); - } - return; - } - - if (event === "org::credit::failure") { - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "error", - payloadData.message || "Unable to assign credit line.", - ); - } - return; - } - - if (event === "org::disband::success") { - if (OrgPortal && OrgPortal.store) { - OrgPortal.store.setModal(null); - OrgPortal.store.setOrgDisbanded(true); - } - return; - } - - if (event === "org::disband::failure") { - if (OrgPortal && OrgPortal.store) { - OrgPortal.store.setModal(null); - } - - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "error", - payloadData.message || "Organization disbanding failed.", - ); - } - return; - } - - if (event === "org::leave::success") { - if (OrgPortal && OrgPortal.store) { - OrgPortal.store.setModal(null); - } - - store.failLogin( - payloadData.message || "You have left the organization.", - ); - store.setView("home"); - return; - } - - if (event === "org::leave::failure") { - if (OrgPortal && OrgPortal.store) { - OrgPortal.store.setModal(null); - } - - if (OrgPortal && OrgPortal.actions) { - OrgPortal.actions.showTreasuryNotice( - "error", - payloadData.message || "Unable to leave the organization.", - ); - } - return; - } - - if (event === "org::portal::revoked") { - if (OrgPortal && OrgPortal.store) { - OrgPortal.store.setModal(null); - } - - store.failLogin( - payloadData.message || - "Organization access is no longer available.", - ); - store.setView("home"); - } - } - - RegistryApp.bridge = { - requestLogin, - requestCreateOrg, - requestDisbandOrg, - requestLeaveOrg, - requestCreditLine, - receive, - sendEvent, - }; - - window.OrgUIBridge = { - requestLogin, - requestCreateOrg, - requestDisbandOrg, - requestLeaveOrg, - requestCreditLine, - receive, - receiveLoginSuccess: (data) => receive("org::login::success", data), - receiveLoginFailure: (data) => receive("org::login::failure", data), - receiveCreateSuccess: (data) => receive("org::create::success", data), - receiveCreateFailure: (data) => receive("org::create::failure", data), - }; -})(); diff --git a/arma/client/addons/org/ui/_site/controls.css b/arma/client/addons/org/ui/_site/controls.css deleted file mode 100644 index 5ed9592..0000000 --- a/arma/client/addons/org/ui/_site/controls.css +++ /dev/null @@ -1,33 +0,0 @@ -.org-secondary-btn { - background: var(--bg-surface); - color: var(--text-main); - border: 1px solid var(--border); - - &:hover { - background: var(--bg-surface-hover); - color: var(--text-main); - } -} - -.org-danger-btn { - background: #7f1d1d; - color: #fef2f2; - - &:hover { - background: #991b1b; - } -} - -.org-icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - padding: 0; -} - -.org-icon { - width: 1rem; - height: 1rem; -} diff --git a/arma/client/addons/org/ui/_site/hero.css b/arma/client/addons/org/ui/_site/hero.css deleted file mode 100644 index eaafcae..0000000 --- a/arma/client/addons/org/ui/_site/hero.css +++ /dev/null @@ -1,41 +0,0 @@ -.org-page-header { - text-align: left; - margin-bottom: 0; -} - -.org-page-heading { - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.org-page-kicker { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); - font-weight: 600; -} - -.org-page-title { - margin: 0; -} - -.org-page-subtitle { - font-size: 0.9rem; - color: var(--text-muted); - margin: 0; -} - -.org-page-meta { - font-size: 0.75rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -@media (max-width: 960px) { - .org-page-heading { - gap: 0.3rem; - } -} diff --git a/arma/client/addons/org/ui/_site/index.html b/arma/client/addons/org/ui/_site/index.html index bc2fa9e..5aaf38e 100644 --- a/arma/client/addons/org/ui/_site/index.html +++ b/arma/client/addons/org/ui/_site/index.html @@ -1,3 +1,4 @@ + @@ -5,88 +6,55 @@ ORBIS - Global Organization Network diff --git a/arma/client/addons/org/ui/_site/logic/portalActions.js b/arma/client/addons/org/ui/_site/logic/portalActions.js deleted file mode 100644 index a9b23ee..0000000 --- a/arma/client/addons/org/ui/_site/logic/portalActions.js +++ /dev/null @@ -1,312 +0,0 @@ -(function () { - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - SharedLogic.createPortalActions = function createPortalActions({ - portalData, - store, - getters, - registryStore, - }) { - class OrgPortalActions { - constructor() { - this.treasuryNoticeTimer = null; - } - - showTreasuryNotice(type, text) { - store.setTreasuryNotice({ type, text }); - - if (this.treasuryNoticeTimer) { - clearTimeout(this.treasuryNoticeTimer); - } - - this.treasuryNoticeTimer = setTimeout(() => { - store.setTreasuryNotice({ type: "", text: "" }); - this.treasuryNoticeTimer = null; - }, 3500); - } - - parseAmount(value) { - const amount = Number(value); - return Number.isFinite(amount) ? Math.round(amount) : 0; - } - - getInputValue(id) { - const el = document.getElementById(id); - return el ? el.value : ""; - } - - closePortal() { - if ( - typeof A3API !== "undefined" && - typeof A3API.SendAlert === "function" - ) { - A3API.SendAlert( - JSON.stringify({ - event: "org::close", - data: {}, - }), - ); - return; - } - - if (registryStore) { - registryStore.setView("home"); - } - } - - openModal(type) { - if ( - (type === "payroll" || - type === "transfer" || - type === "credit") && - !getters.canManageTreasury() - ) { - this.showTreasuryNotice( - "error", - "Only the organization leader or CEO can manage treasury actions.", - ); - return; - } - - if (type === "disband" && !getters.canDisbandOrg()) { - return; - } - - if (type === "leave" && !getters.canLeaveOrg()) { - return; - } - - store.setModal({ type }); - } - - closeModal() { - store.setModal(null); - } - - removeMember(member) { - if (!getters.canManageMembers()) { - return false; - } - - if (getters.isProtectedMember(member)) { - return false; - } - - const memberUid = getters.getMemberUid(member); - const memberName = getters.getMemberName(member); - - store.setMembers((currentMembers) => - currentMembers.filter((entry) => - memberUid - ? entry.uid !== memberUid - : entry.name !== memberName, - ), - ); - store.setCreditLines((currentLines) => - currentLines.filter((line) => - memberUid - ? line.uid !== memberUid - : line.member !== memberName, - ), - ); - return true; - } - - disbandOrganization() { - if (!getters.canDisbandOrg()) { - return false; - } - - const bridge = window.RegistryApp - ? window.RegistryApp.bridge - : null; - - if (!bridge || typeof bridge.requestDisbandOrg !== "function") { - this.showTreasuryNotice( - "error", - "Disband bridge is unavailable.", - ); - return false; - } - - this.closeModal(); - bridge.requestDisbandOrg(); - return true; - } - - leaveOrganization() { - if (!getters.canLeaveOrg()) { - return false; - } - - const bridge = window.RegistryApp - ? window.RegistryApp.bridge - : null; - - if (!bridge || typeof bridge.requestLeaveOrg !== "function") { - this.showTreasuryNotice( - "error", - "Leave bridge is unavailable.", - ); - return false; - } - - this.closeModal(); - bridge.requestLeaveOrg(); - return true; - } - - runPayroll(amountPerMember) { - if (!getters.canManageTreasury()) { - this.showTreasuryNotice( - "error", - "Only the organization leader or CEO can manage treasury actions.", - ); - return false; - } - - const members = store.getMembers(); - const funds = store.getFunds(); - - if (members.length === 0) { - this.showTreasuryNotice( - "error", - "No members available for payroll.", - ); - return false; - } - - if (amountPerMember <= 0) { - this.showTreasuryNotice( - "error", - "Enter a valid payroll amount.", - ); - return false; - } - - const total = amountPerMember * members.length; - if (total > funds) { - this.showTreasuryNotice( - "error", - "Insufficient org funds for payroll.", - ); - return false; - } - - store.setFunds(funds - total); - this.showTreasuryNotice( - "success", - `Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`, - ); - return true; - } - - sendFundsToMember(memberName, amount) { - if (!getters.canManageTreasury()) { - this.showTreasuryNotice( - "error", - "Only the organization leader or CEO can manage treasury actions.", - ); - return false; - } - - const funds = store.getFunds(); - - if (!memberName) { - this.showTreasuryNotice( - "error", - "Select a member to receive funds.", - ); - return false; - } - - if (amount <= 0) { - this.showTreasuryNotice( - "error", - "Enter a valid transfer amount.", - ); - return false; - } - - if (amount > funds) { - this.showTreasuryNotice( - "error", - "Insufficient org funds for this transfer.", - ); - return false; - } - - store.setFunds(funds - amount); - this.showTreasuryNotice( - "success", - `${getters.formatCurrency(amount)} sent to ${memberName}.`, - ); - return true; - } - - grantCreditLine(memberUid, amount) { - if (!getters.canManageTreasury()) { - this.showTreasuryNotice( - "error", - "Only the organization leader or CEO can manage treasury actions.", - ); - return false; - } - - if (!memberUid) { - this.showTreasuryNotice( - "error", - "Select a member for the credit line.", - ); - return false; - } - - if (amount <= 0) { - this.showTreasuryNotice( - "error", - "Enter a valid credit line amount.", - ); - return false; - } - - const member = store - .getMembers() - .find( - (entry) => - getters.getMemberUid(entry) === memberUid, - ); - const memberName = member - ? getters.getMemberName(member) - : ""; - - if (!memberName) { - this.showTreasuryNotice( - "error", - "Selected member was not found in the organization roster.", - ); - return false; - } - - const bridge = window.RegistryApp - ? window.RegistryApp.bridge - : null; - - if (!bridge || typeof bridge.requestCreditLine !== "function") { - this.showTreasuryNotice( - "error", - "Credit line bridge is unavailable.", - ); - return false; - } - - return bridge.requestCreditLine({ - memberUid, - memberName, - amount, - }); - } - } - - return new OrgPortalActions(); - }; -})(); diff --git a/arma/client/addons/org/ui/_site/logic/portalGetters.js b/arma/client/addons/org/ui/_site/logic/portalGetters.js deleted file mode 100644 index d376c23..0000000 --- a/arma/client/addons/org/ui/_site/logic/portalGetters.js +++ /dev/null @@ -1,183 +0,0 @@ -(function () { - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - SharedLogic.createPortalGetters = function createPortalGetters({ - portalData, - session, - }) { - class OrgPortalGetters { - formatCurrency(value) { - return "$" + Number(value || 0).toLocaleString(); - } - - formatVehicleType(type) { - if (!type) { - return ""; - } - - return type.charAt(0).toUpperCase() + type.slice(1); - } - - formatAssetType(type) { - if (!type) { - return ""; - } - - return type.charAt(0).toUpperCase() + type.slice(1); - } - - formatDisplayName(value) { - if (!value) { - return ""; - } - - return String(value) - .trim() - .split(/\s+/) - .map((part) => { - if (!part) { - return ""; - } - - return ( - part.charAt(0).toUpperCase() + - part.slice(1).toLowerCase() - ); - }) - .join(" "); - } - - getAssetReadiness() { - if (portalData.fleet.length === 0) { - return null; - } - - const total = portalData.fleet.reduce( - (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), - 0, - ); - return Math.round(total / portalData.fleet.length); - } - - getNormalizedRole() { - return String(session.role || "") - .trim() - .toUpperCase(); - } - - isDefaultOrg() { - return ( - portalData.org.isDefault === true || - String(portalData.org.tag || "") - .trim() - .toUpperCase() === "DEFAULT" - ); - } - - isOrgOwner() { - const ownerUid = String( - portalData.org.ownerUid || portalData.org.owner || "", - ) - .trim() - .toLowerCase(); - const actorUid = String(session.actorUid || "") - .trim() - .toLowerCase(); - - if (ownerUid && actorUid) { - return actorUid === ownerUid; - } - - return ( - String(session.actorName || "") - .trim() - .toLowerCase() === - String(portalData.org.owner || "") - .trim() - .toLowerCase() - ); - } - - isSessionCeo() { - return session.ceo === true; - } - - isOrgLeaderOrCeo() { - return ( - this.isOrgOwner() || - this.getNormalizedRole() === "LEADER" || - (this.isDefaultOrg() && this.isSessionCeo()) - ); - } - - canManageMembers() { - return this.isOrgLeaderOrCeo(); - } - - canManageTreasury() { - return this.isOrgLeaderOrCeo(); - } - - canDisbandOrg() { - return this.isOrgOwner() && !this.isDefaultOrg(); - } - - canLeaveOrg() { - return !this.isDefaultOrg() && !this.isOrgOwner(); - } - - getMemberName(member) { - if (member && typeof member === "object") { - return String(member.name || ""); - } - - return String(member || ""); - } - - getMemberUid(member) { - if (member && typeof member === "object") { - return String(member.uid || ""); - } - - return ""; - } - - isOwnerMember(member) { - return ( - this.getMemberName(member).trim().toLowerCase() === - String(portalData.org.owner || "") - .trim() - .toLowerCase() - ); - } - - isCurrentMember(member) { - const memberUid = this.getMemberUid(member) - .trim() - .toLowerCase(); - const actorUid = String(session.actorUid || "") - .trim() - .toLowerCase(); - - if (memberUid && actorUid) { - return memberUid === actorUid; - } - - return ( - this.getMemberName(member).trim().toLowerCase() === - String(session.actorName || "") - .trim() - .toLowerCase() - ); - } - - isProtectedMember(member) { - return ( - this.isOwnerMember(member) || this.isCurrentMember(member) - ); - } - } - - return new OrgPortalGetters(); - }; -})(); diff --git a/arma/client/addons/org/ui/_site/logic/portalStore.js b/arma/client/addons/org/ui/_site/logic/portalStore.js deleted file mode 100644 index c9e1814..0000000 --- a/arma/client/addons/org/ui/_site/logic/portalStore.js +++ /dev/null @@ -1,37 +0,0 @@ -(function () { - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - SharedLogic.createPortalStore = function createPortalStore({ - createSignal, - portalData, - }) { - class OrgPortalStore { - constructor() { - [this.getFunds, this.setFunds] = createSignal(portalData.funds); - [this.getMembers, this.setMembers] = createSignal([ - ...portalData.members, - ]); - [this.getCreditLines, this.setCreditLines] = createSignal([ - ...portalData.creditLines, - ]); - [this.getTreasuryNotice, this.setTreasuryNotice] = createSignal( - { - type: "", - text: "", - }, - ); - [this.getModal, this.setModal] = createSignal(null); - [this.getOrgDisbanded, this.setOrgDisbanded] = - createSignal(false); - } - - hydrateFromPayload(payload) { - this.setFunds(payload.portalData.funds || 0); - this.setMembers([...(payload.portalData.members || [])]); - this.setCreditLines([...(payload.portalData.creditLines || [])]); - } - } - - return new OrgPortalStore(); - }; -})(); diff --git a/arma/client/addons/org/ui/_site/logic/registryStore.js b/arma/client/addons/org/ui/_site/logic/registryStore.js deleted file mode 100644 index e7db429..0000000 --- a/arma/client/addons/org/ui/_site/logic/registryStore.js +++ /dev/null @@ -1,69 +0,0 @@ -(function () { - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - SharedLogic.createRegistryStore = function createRegistryStore({ - createSignal, - onHydratePortal, - }) { - class RegistryStore { - constructor() { - [this.getView, this.setView] = createSignal("home"); - [this.getIsAuthenticating, this.setIsAuthenticating] = - createSignal(false); - [this.getLoginError, this.setLoginError] = createSignal(""); - [this.getIsCreating, this.setIsCreating] = createSignal(false); - [this.getCreateError, this.setCreateError] = createSignal(""); - } - - startLogin() { - this.setLoginError(""); - this.setIsAuthenticating(true); - } - - startCreate() { - this.setCreateError(""); - this.setIsCreating(true); - } - - failLogin(message) { - this.setIsAuthenticating(false); - this.setLoginError(message || "Authentication failed."); - } - - failCreate(message) { - this.setIsCreating(false); - this.setCreateError( - message || "Organization registration failed.", - ); - } - - hydratePortal(payload) { - return Boolean(onHydratePortal && onHydratePortal(payload)); - } - - completeLogin(payload) { - if (!this.hydratePortal(payload)) { - this.failLogin("Login response was missing portal data."); - return; - } - - this.setLoginError(""); - this.setIsAuthenticating(false); - } - - completeCreate(payload) { - if (!this.hydratePortal(payload)) { - this.failCreate( - "Organization registration response was missing portal data.", - ); - return; - } - - this.setCreateError(""); - this.setIsCreating(false); - } - } - - return new RegistryStore(); - }; -})(); diff --git a/arma/client/addons/org/ui/_site/base.css b/arma/client/addons/org/ui/_site/org-ui.css similarity index 68% rename from arma/client/addons/org/ui/_site/base.css rename to arma/client/addons/org/ui/_site/org-ui.css index 376a1ab..d74b453 100644 --- a/arma/client/addons/org/ui/_site/base.css +++ b/arma/client/addons/org/ui/_site/org-ui.css @@ -1,12 +1,10 @@ +/* Generated by tools/build-webui.mjs for Org UI styles. Do not edit directly. */ :root { --bg-app: #fdfcf8; --bg-surface: #ffffff; --bg-surface-hover: #f1f5f9; --primary: #475569; --primary-hover: #1e293b; - --window-blue: #12325b; - --window-blue-border: #214978; - --window-blue-highlight: #d7e5f8; --text-main: #1f2937; --text-muted: #64748b; --text-inverse: #f8fafc; @@ -21,6 +19,12 @@ body { height: 100%; } +*, +*::before, +*::after { + box-sizing: border-box; +} + body { font-family: "Inter", @@ -47,6 +51,14 @@ body { overflow: hidden; } +#org-portal-frame-root { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + main { display: flex; flex-direction: column; @@ -56,85 +68,6 @@ main { overscroll-behavior: contain; } -.window-titlebar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.8rem 1.25rem; - background: linear-gradient(180deg, var(--window-blue) 0%, #0d2643 100%); - border-bottom: 1px solid var(--window-blue-border); - color: var(--text-inverse); - box-shadow: 0 10px 24px rgb(18 50 91 / 0.24); - position: sticky; - top: 0; - z-index: 30; - flex-shrink: 0; -} - -.window-titlebar-brand { - display: flex; - flex-direction: column; - gap: 0.1rem; -} - -.window-titlebar-kicker { - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - color: rgb(215 229 248 / 0.78); -} - -.window-titlebar-title { - font-size: 0.95rem; - font-weight: 700; - letter-spacing: 0.04em; - color: var(--text-inverse); -} - -.window-titlebar-controls { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.window-control-btn { - min-width: 2.5rem; - padding: 0.45rem 0.7rem; - border-radius: 6px; - border: 1px solid rgb(215 229 248 / 0.22); - background: rgb(255 255 255 / 0.08); - color: var(--window-blue-highlight); - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: not-allowed; - box-shadow: none; - transform: none; -} - -.window-control-btn:hover { - background: rgb(255 255 255 / 0.08); - box-shadow: none; - transform: none; -} - -.window-control-btn:disabled { - opacity: 0.55; -} - -.window-control-btn.is-close { - cursor: pointer; - opacity: 1; - border-color: rgb(255 255 255 / 0.24); -} - -.window-control-btn.is-close:hover { - background: rgb(255 255 255 / 0.18); -} - .container { max-width: 1200px; width: 100%; @@ -254,17 +187,77 @@ button { } } +.org-secondary-btn { + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + + &:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + } +} + +.org-danger-btn { + background: #7f1d1d; + color: #fef2f2; + + &:hover { + background: #991b1b; + } +} + +.org-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; +} + +.org-icon { + width: 1rem; + height: 1rem; +} + +.org-page-header { + text-align: left; + margin-bottom: 0; +} + +.org-page-heading { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.org-page-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +.org-page-title { + margin: 0; +} + +.org-page-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.org-page-meta { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + @media (max-width: 960px) { - .window-titlebar { - flex-direction: column; - align-items: flex-start; - } - - .window-titlebar-controls { - width: 100%; - justify-content: flex-end; - } - .container { padding: 1.5rem; } @@ -281,4 +274,8 @@ button { .footer .wrapper { grid-template-columns: 1fr; } + + .org-page-heading { + gap: 0.3rem; + } } diff --git a/arma/client/addons/org/ui/_site/org-ui.js b/arma/client/addons/org/ui/_site/org-ui.js new file mode 100644 index 0000000..b09fdcf --- /dev/null +++ b/arma/client/addons/org/ui/_site/org-ui.js @@ -0,0 +1,4082 @@ +/* Generated by tools/build-webui.mjs for Org UI app. Do not edit directly. */ +(function () { + const runtime = window.ForgeWebUI; + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + + RegistryApp.runtime = runtime; + OrgPortal.runtime = runtime; + window.AppRuntime = runtime; +})(); + +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { createSignal } = RegistryApp.runtime; + + class RegistryStore { + constructor() { + [this.getView, this.setView] = createSignal("home"); + [this.getIsAuthenticating, this.setIsAuthenticating] = + createSignal(false); + [this.getLoginError, this.setLoginError] = createSignal(""); + [this.getIsCreating, this.setIsCreating] = createSignal(false); + [this.getCreateError, this.setCreateError] = createSignal(""); + } + + startLogin() { + this.setLoginError(""); + this.setIsAuthenticating(true); + } + + startCreate() { + this.setCreateError(""); + this.setIsCreating(true); + } + + failLogin(message) { + this.setIsAuthenticating(false); + this.setLoginError(message || "Authentication failed."); + } + + failCreate(message) { + this.setIsCreating(false); + this.setCreateError(message || "Organization registration failed."); + } + + hydratePortal(payload) { + const portalApi = + window.OrgPortal && window.OrgPortal.data + ? window.OrgPortal.data + : null; + const portalStore = + window.OrgPortal && window.OrgPortal.store + ? window.OrgPortal.store + : null; + const portalData = + payload && payload.portalData ? payload.portalData : null; + const sessionData = + payload && payload.session ? payload.session : null; + + if ( + !portalApi || + typeof portalApi.applyLoginPayload !== "function" || + !portalStore || + typeof portalStore.hydrateFromPayload !== "function" || + !portalData || + !sessionData + ) { + return false; + } + + portalApi.applyLoginPayload(payload); + portalStore.hydrateFromPayload(payload); + return true; + } + + completeLogin(payload) { + if (!this.hydratePortal(payload)) { + this.failLogin("Login response was missing portal data."); + return; + } + + this.setLoginError(""); + this.setIsAuthenticating(false); + this.setView("portal"); + } + + completeCreate(payload) { + if (!this.hydratePortal(payload)) { + this.failCreate( + "Organization registration response was missing portal data.", + ); + return; + } + + this.setCreateError(""); + this.setIsCreating(false); + this.setView("portal"); + } + } + + RegistryApp.store = new RegistryStore(); +})(); + +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const store = RegistryApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "org::close", + globalName: "ForgeBridge", + readyEvent: "org::ready", + }); + + function sendEvent(event, data) { + return bridge.send(event, data); + } + + function requestLogin(credentials) { + store.startLogin(); + + const sent = sendEvent("org::login::request", credentials); + if (sent) { + return; + } + + store.failLogin("Arma login bridge is unavailable."); + } + + function requestCreateOrg(registration) { + store.startCreate(); + + const sent = sendEvent("org::create::request", registration); + if (sent) { + return; + } + + store.failCreate("Arma registration bridge is unavailable."); + } + + function requestDisbandOrg() { + const sent = sendEvent("org::disband::request", {}); + if (sent) { + return; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma disband bridge is unavailable.", + ); + } + } + + function requestLeaveOrg() { + const sent = sendEvent("org::leave::request", {}); + if (sent) { + return; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma leave bridge is unavailable.", + ); + } + } + + function requestCreditLine(payload) { + const sent = sendEvent("org::credit::request", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma credit line bridge is unavailable.", + ); + } + + return false; + } + + bridge.on("org::login::success", (payloadData) => { + store.completeLogin(payloadData); + }); + + bridge.on("org::login::failure", (payloadData) => { + store.failLogin(payloadData.message || "Authentication failed."); + }); + + bridge.on("org::create::success", (payloadData) => { + store.completeCreate(payloadData); + }); + + bridge.on("org::create::failure", (payloadData) => { + store.failCreate( + payloadData.message || "Organization registration failed.", + ); + }); + + bridge.on("org::sync", (payloadData) => { + if (store && typeof store.hydratePortal === "function") { + store.hydratePortal(payloadData); + } + }); + + bridge.on("org::credit::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "success", + payloadData.message || "Credit line assigned.", + ); + } + }); + + bridge.on("org::credit::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to assign credit line.", + ); + } + }); + + bridge.on("org::member::creditUpdated", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (!OrgPortal || !OrgPortal.store) { + return; + } + + OrgPortal.store.setCreditLines((currentLines) => { + const nextLine = { + amount: payloadData.amount || 0, + member: payloadData.memberName || "", + uid: payloadData.memberUid || "", + }; + const matchIndex = currentLines.findIndex( + (line) => line.uid === nextLine.uid, + ); + + if (matchIndex === -1) { + return [...currentLines, nextLine]; + } + + return currentLines.map((line, index) => + index === matchIndex ? nextLine : line, + ); + }); + }); + + bridge.on("org::disband::success", () => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + OrgPortal.store.setOrgDisbanded(true); + } + }); + + bridge.on("org::disband::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Organization disbanding failed.", + ); + } + }); + + bridge.on("org::leave::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + store.failLogin( + payloadData.message || "You have left the organization.", + ); + store.setView("home"); + }); + + bridge.on("org::leave::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to leave the organization.", + ); + } + }); + + bridge.on("org::portal::revoked", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + store.failLogin( + payloadData.message || + "Organization access is no longer available.", + ); + store.setView("home"); + }); + + RegistryApp.bridge = { + close: bridge.close, + ready: bridge.ready, + receive: bridge.receive, + requestLogin, + requestCreateOrg, + requestDisbandOrg, + requestLeaveOrg, + requestCreditLine, + sendEvent, + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const staticOrgProfile = { + type: "Organization", + status: "Operational", + headquarters: "ArmA Verse", + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + function replaceArray(target, source) { + target.splice(0, target.length, ...cloneValue(source)); + } + + OrgPortal.data = { + portalData: { + org: Object.assign( + { + name: "", + tag: "", + owner: "", + ownerUid: "", + isDefault: false, + }, + staticOrgProfile, + ), + funds: 0, + reputation: 0, + creditLines: [], + members: [], + fleet: [], + assets: [], + activity: [], + roadmap: [ + { + name: "Contracts Board", + status: "Planned", + detail: "Track payouts, assignments, and claim approvals.", + }, + { + name: "Diplomacy", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Logistics Queue", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Permissions", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + ], + }, + session: { + actorName: "", + actorUid: "", + role: "", + ceo: false, + }, + applyLoginPayload(payload) { + replaceObject( + this.portalData.org, + Object.assign( + {}, + payload.portalData.org || {}, + staticOrgProfile, + ), + ); + this.portalData.funds = payload.portalData.funds || 0; + this.portalData.reputation = payload.portalData.reputation || 0; + replaceArray( + this.portalData.creditLines, + payload.portalData.creditLines || [], + ); + + replaceArray( + this.portalData.members, + payload.portalData.members || [], + ); + replaceArray(this.portalData.fleet, payload.portalData.fleet || []); + replaceArray( + this.portalData.assets, + payload.portalData.assets || [], + ); + replaceArray( + this.portalData.activity, + payload.portalData.activity || [], + ); + replaceArray( + this.portalData.roadmap, + payload.portalData.roadmap || [], + ); + + replaceObject(this.session, payload.session || {}); + }, + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { createSignal } = window.RegistryApp.runtime; + const { portalData } = OrgPortal.data; + + class OrgPortalStore { + constructor() { + [this.getFunds, this.setFunds] = createSignal(portalData.funds); + [this.getReputation, this.setReputation] = createSignal( + portalData.reputation, + ); + [this.getMembers, this.setMembers] = createSignal([ + ...portalData.members, + ]); + [this.getCreditLines, this.setCreditLines] = createSignal([ + ...portalData.creditLines, + ]); + [this.getFleet, this.setFleet] = createSignal([ + ...portalData.fleet, + ]); + [this.getAssets, this.setAssets] = createSignal([ + ...portalData.assets, + ]); + [this.getActivity, this.setActivity] = createSignal([ + ...portalData.activity, + ]); + [this.getTreasuryNotice, this.setTreasuryNotice] = createSignal({ + type: "", + text: "", + }); + [this.getModal, this.setModal] = createSignal(null); + [this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); + } + + hydrateFromPayload(payload) { + const nextPortalData = payload.portalData || {}; + + this.setFunds(nextPortalData.funds || 0); + this.setReputation(nextPortalData.reputation || 0); + this.setMembers([...(nextPortalData.members || [])]); + this.setCreditLines([...(nextPortalData.creditLines || [])]); + this.setFleet([...(nextPortalData.fleet || [])]); + this.setAssets([...(nextPortalData.assets || [])]); + this.setActivity([...(nextPortalData.activity || [])]); + } + } + + OrgPortal.store = new OrgPortalStore(); +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData, session } = OrgPortal.data; + + class OrgPortalGetters { + formatCurrency(value) { + return "$" + Number(value || 0).toLocaleString(); + } + + formatVehicleType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatAssetType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatDisplayName(value) { + if (!value) { + return ""; + } + + return String(value) + .trim() + .split(/\s+/) + .map((part) => { + if (!part) { + return ""; + } + + return ( + part.charAt(0).toUpperCase() + + part.slice(1).toLowerCase() + ); + }) + .join(" "); + } + + getAssetReadiness() { + const fleet = OrgPortal.store + ? OrgPortal.store.getFleet() + : portalData.fleet; + if (fleet.length === 0) { + return null; + } + + const total = fleet.reduce( + (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), + 0, + ); + return Math.round(total / fleet.length); + } + + getNormalizedRole() { + return String(session.role || "") + .trim() + .toUpperCase(); + } + + isDefaultOrg() { + return ( + portalData.org.isDefault === true || + String(portalData.org.tag || "") + .trim() + .toUpperCase() === "DEFAULT" + ); + } + + isOrgOwner() { + const ownerUid = String( + portalData.org.ownerUid || portalData.org.owner || "", + ) + .trim() + .toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (ownerUid && actorUid) { + return actorUid === ownerUid; + } + + return ( + String(session.actorName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isSessionCeo() { + return session.ceo === true; + } + + isOrgLeaderOrCeo() { + return ( + this.isOrgOwner() || + this.getNormalizedRole() === "LEADER" || + (this.isDefaultOrg() && this.isSessionCeo()) + ); + } + + canManageMembers() { + return this.isOrgLeaderOrCeo(); + } + + canManageTreasury() { + return this.isOrgLeaderOrCeo(); + } + + canDisbandOrg() { + return this.isOrgOwner() && !this.isDefaultOrg(); + } + + canLeaveOrg() { + return !this.isDefaultOrg() && !this.isOrgOwner(); + } + + getMemberName(member) { + if (member && typeof member === "object") { + return String(member.name || ""); + } + + return String(member || ""); + } + + getMemberUid(member) { + if (member && typeof member === "object") { + return String(member.uid || ""); + } + + return ""; + } + + isOwnerMember(member) { + return ( + this.getMemberName(member).trim().toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isCurrentMember(member) { + const memberUid = this.getMemberUid(member).trim().toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (memberUid && actorUid) { + return memberUid === actorUid; + } + + return ( + this.getMemberName(member).trim().toLowerCase() === + String(session.actorName || "") + .trim() + .toLowerCase() + ); + } + + isProtectedMember(member) { + return this.isOwnerMember(member) || this.isCurrentMember(member); + } + } + + OrgPortal.getters = new OrgPortalGetters(); +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const registryStore = window.RegistryApp.store; + + class OrgPortalActions { + constructor() { + this.treasuryNoticeTimer = null; + } + + showTreasuryNotice(type, text) { + store.setTreasuryNotice({ type, text }); + + if (this.treasuryNoticeTimer) { + clearTimeout(this.treasuryNoticeTimer); + } + + this.treasuryNoticeTimer = setTimeout(() => { + store.setTreasuryNotice({ type: "", text: "" }); + this.treasuryNoticeTimer = null; + }, 3500); + } + + parseAmount(value) { + const amount = Number(value); + return Number.isFinite(amount) ? Math.round(amount) : 0; + } + + getInputValue(id) { + const el = document.getElementById(id); + return el ? el.value : ""; + } + + closePortal() { + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (bridge && typeof bridge.close === "function") { + bridge.close({}); + return; + } + + if (registryStore) { + registryStore.setView("home"); + } + } + + openModal(type) { + if ( + (type === "payroll" || + type === "transfer" || + type === "credit") && + !getters.canManageTreasury() + ) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return; + } + + if (type === "disband" && !getters.canDisbandOrg()) { + return; + } + + if (type === "leave" && !getters.canLeaveOrg()) { + return; + } + + store.setModal({ type }); + } + + closeModal() { + store.setModal(null); + } + + removeMember(member) { + if (!getters.canManageMembers()) { + return false; + } + + if (getters.isProtectedMember(member)) { + return false; + } + + const memberUid = getters.getMemberUid(member); + const memberName = getters.getMemberName(member); + + store.setMembers((currentMembers) => + currentMembers.filter((entry) => + memberUid + ? entry.uid !== memberUid + : entry.name !== memberName, + ), + ); + store.setCreditLines((currentLines) => + currentLines.filter((line) => + memberUid + ? line.uid !== memberUid + : line.member !== memberName, + ), + ); + return true; + } + + disbandOrganization() { + if (!getters.canDisbandOrg()) { + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestDisbandOrg !== "function") { + this.showTreasuryNotice( + "error", + "Disband bridge is unavailable.", + ); + return false; + } + + this.closeModal(); + bridge.requestDisbandOrg(); + return true; + } + + leaveOrganization() { + if (!getters.canLeaveOrg()) { + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestLeaveOrg !== "function") { + this.showTreasuryNotice( + "error", + "Leave bridge is unavailable.", + ); + return false; + } + + this.closeModal(); + bridge.requestLeaveOrg(); + return true; + } + + runPayroll(amountPerMember) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const members = store.getMembers(); + const funds = store.getFunds(); + + if (members.length === 0) { + this.showTreasuryNotice( + "error", + "No members available for payroll.", + ); + return false; + } + + if (amountPerMember <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid payroll amount.", + ); + return false; + } + + const total = amountPerMember * members.length; + if (total > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for payroll.", + ); + return false; + } + + store.setFunds(funds - total); + this.showTreasuryNotice( + "success", + `Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`, + ); + return true; + } + + sendFundsToMember(memberName, amount) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const funds = store.getFunds(); + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Select a member to receive funds.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid transfer amount.", + ); + return false; + } + + if (amount > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for this transfer.", + ); + return false; + } + + store.setFunds(funds - amount); + this.showTreasuryNotice( + "success", + `${getters.formatCurrency(amount)} sent to ${memberName}.`, + ); + return true; + } + + grantCreditLine(memberUid, amount) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + if (!memberUid) { + this.showTreasuryNotice( + "error", + "Select a member for the credit line.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid credit line amount.", + ); + return false; + } + + const member = store + .getMembers() + .find((entry) => getters.getMemberUid(entry) === memberUid); + const memberName = member ? getters.getMemberName(member) : ""; + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Selected member was not found in the organization roster.", + ); + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestCreditLine !== "function") { + this.showTreasuryNotice( + "error", + "Credit line bridge is unavailable.", + ); + return false; + } + + return bridge.requestCreditLine({ + memberUid, + memberName, + amount, + }); + } + } + + OrgPortal.actions = new OrgPortalActions(); +})(); + +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-navbar"; + const scopeSelector = `[${scopeAttr}]`; + const navbarCss = ` +${scopeSelector} { + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); +} + +${scopeSelector} .app-navbar-inner { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 1rem 2rem; + box-sizing: border-box; +} + +${scopeSelector} .app-navbar-brand { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +${scopeSelector} .app-navbar-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +${scopeSelector} .app-navbar-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-hover); + letter-spacing: -0.025em; +} + +${scopeSelector} .app-navbar-actions { + display: flex; + align-items: center; + gap: 1.5rem; +} + +${scopeSelector} .app-navbar-view { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 600; +} + +${scopeSelector} .app-close-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + padding: 0.5rem 1rem; + font-size: 0.85rem; +} + +${scopeSelector} .app-close-btn:hover { + background: var(--bg-surface-hover); + color: var(--primary-hover); + border-color: var(--primary); + transform: none; + box-shadow: none; +} + +@media (max-width: 960px) { + ${scopeSelector} .app-navbar-inner { + flex-direction: column; + align-items: flex-start; + padding: 1rem 1.5rem; + } + + ${scopeSelector} .app-navbar-actions { + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Navbar = function Navbar({ + kicker = "ORBIS", + title = "", + viewLabel = "", + actionLabel = "", + onAction = null, + }) { + ensureScopedStyle("shared-navbar", navbarCss); + + return h( + "nav", + { className: "app-navbar", [scopeAttr]: "" }, + h( + "div", + { className: "app-navbar-inner" }, + h( + "div", + { className: "app-navbar-brand" }, + h("span", { className: "app-navbar-kicker" }, kicker), + h("span", { className: "app-navbar-title" }, title), + ), + h( + "div", + { className: "app-navbar-actions" }, + h("span", { className: "app-navbar-view" }, viewLabel), + actionLabel && typeof onAction === "function" + ? h( + "button", + { + type: "button", + className: "app-close-btn", + onClick: onAction, + }, + actionLabel, + ) + : null, + ), + ), + ); + }; +})(); + +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Header = function Header({ + title, + subtitle = "Organization Registration & Management Portal", + onTitleClick = null, + }) { + return h( + "div", + { className: "header" }, + h( + "h1", + { + style: { cursor: onTitleClick ? "pointer" : "default" }, + onClick: onTitleClick, + }, + title, + ), + h("p", null, subtitle), + ); + }; +})(); + +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Hero = function Hero({ + className = "", + kicker = "", + title = "", + subtitle = "", + meta = "", + }) { + const finalClassName = [ + "card org-panel org-span-12 org-page-header", + className, + ] + .filter(Boolean) + .join(" "); + + return h( + "section", + { className: finalClassName }, + h( + "div", + { className: "org-page-heading" }, + h("span", { className: "org-page-kicker" }, kicker), + h("h1", { className: "org-page-title" }, title), + h("p", { className: "org-page-subtitle" }, subtitle), + h("span", { className: "org-page-meta" }, meta), + ), + ); + }; +})(); + +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Footer = function Footer({ sections = [] }) { + return h( + "div", + { className: "footer" }, + h( + "div", + { className: "wrapper" }, + ...sections.map((section) => + h( + "div", + null, + h("h3", null, section.title), + h( + "ul", + { style: { listStyleType: "none", padding: 0 } }, + ...(section.items || []).map((item) => + h("li", null, item), + ), + ), + ), + ), + ), + ); + }; +})(); + +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-modal"; + const scopeSelector = `[${scopeAttr}]`; + const modalCss = ` +${scopeSelector} { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 0.38); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 20; +} + +${scopeSelector} .app-modal-card { + width: min(100%, 30rem); + margin-bottom: 0; + text-align: left; +} + +${scopeSelector} .app-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +${scopeSelector} .app-modal-title { + margin: 0; + color: var(--primary-hover); + font-size: 1.45rem; +} + +${scopeSelector} .app-modal-close { + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + box-shadow: none; + transform: none; +} + +${scopeSelector} .app-modal-close:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + box-shadow: none; + transform: none; +} + +${scopeSelector} .app-modal-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +${scopeSelector} .app-modal-form label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; +} + +${scopeSelector} .app-modal-form input, +${scopeSelector} .app-modal-form select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +${scopeSelector} .app-modal-form input:focus, +${scopeSelector} .app-modal-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12); +} + +${scopeSelector} .app-modal-form input:disabled, +${scopeSelector} .app-modal-form select:disabled { + background: #f1f5f9; + color: var(--text-muted); + cursor: not-allowed; +} + +${scopeSelector} .app-modal-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +${scopeSelector} .app-modal-actions button + button, +${scopeSelector} .app-modal-danger-actions button + button { + margin-left: 0; +} + +${scopeSelector} .app-modal-danger { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid #fecaca; + border-radius: var(--radius); + background: #fff1f2; + align-items: flex-start; +} + +${scopeSelector} .app-modal-danger p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .app-modal-danger-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .app-modal-head, + ${scopeSelector} .app-modal-danger { + flex-direction: column; + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Modal = function Modal({ + title = "", + body = null, + onClose = null, + }) { + ensureScopedStyle("shared-modal", modalCss); + + return h( + "div", + { + className: "app-modal-backdrop", + [scopeAttr]: "", + onClick: (e) => { + if (e.target === e.currentTarget && onClose) { + onClose(); + } + }, + }, + h( + "div", + { className: "card app-modal-card" }, + h( + "div", + { className: "app-modal-head" }, + h( + "div", + null, + h("h2", { className: "app-modal-title" }, title), + ), + h( + "button", + { + type: "button", + className: "app-modal-close", + onClick: onClose, + "aria-label": "Close dialog", + }, + "x", + ), + ), + body, + ), + ); + }; +})(); + +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-panel-card"; + const scopeSelector = `[${scopeAttr}]`; + const panelCardCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +${scopeSelector} .org-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +${scopeSelector} .org-panel-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + +${scopeSelector} .org-eyebrow { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +${scopeSelector} .org-panel-title { + margin: 0; + color: var(--primary-hover); + font-size: 1.45rem; +} + +${scopeSelector} .org-panel-subtitle { + margin: 0.35rem 0 0; + color: var(--text-muted); + font-size: 0.95rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-panel-head { + flex-direction: column; + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.PanelCard = function PanelCard({ + className = "", + eyebrow = "", + title = "", + subtitle = "", + headerExtras = null, + body = null, + rootProps = {}, + }) { + const finalClassName = ["card org-panel", className] + .filter(Boolean) + .join(" "); + ensureScopedStyle("shared-panel-card", panelCardCss); + + return h( + "section", + { className: finalClassName, [scopeAttr]: "", ...rootProps }, + h( + "div", + { className: "org-panel-head" }, + h( + "div", + null, + eyebrow + ? h("div", { className: "org-eyebrow" }, eyebrow) + : null, + h("h2", { className: "org-panel-title" }, title), + subtitle + ? h("p", { className: "org-panel-subtitle" }, subtitle) + : null, + ), + headerExtras, + ), + h("div", { className: "org-panel-body" }, body), + ); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-metric-card"; + const scopeSelector = `[${scopeAttr}]`; + const metricCardCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +${scopeSelector}:nth-child(4n + 2), +${scopeSelector}:nth-child(4n + 3) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%); + border-color: rgb(100 116 139 / 0.35); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6); +} + +${scopeSelector} .org-metric-label { + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +${scopeSelector} .org-metric-value { + font-size: 1.8rem; + color: var(--primary-hover); + line-height: 1.1; +} + +${scopeSelector}:nth-child(4n + 2) .org-metric-value, +${scopeSelector}:nth-child(4n + 3) .org-metric-value { + color: #334155; +} + +${scopeSelector} .org-metric-note { + color: var(--text-muted); + font-size: 0.9rem; +} + +@media (max-width: 960px) { + ${scopeSelector}:nth-child(4n + 3) { + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-color: var(--border); + box-shadow: none; + } + + ${scopeSelector}:nth-child(4n + 3) .org-metric-value { + color: var(--primary-hover); + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.MetricCard = function MetricCard( + label, + value, + note, + ) { + ensureScopedStyle("portal-metric-card", metricCardCss); + + return h( + "div", + { className: "org-metric-card", [scopeAttr]: "" }, + h("span", { className: "org-metric-label" }, label), + h("strong", { className: "org-metric-value" }, value), + h("span", { className: "org-metric-note" }, note), + ); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-simple-stat"; + const scopeSelector = `[${scopeAttr}]`; + const simpleStatCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 90px; +} + +${scopeSelector} .org-simple-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-simple-value { + font-size: 0.95rem; + color: var(--text-main); +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.SimpleStat = function SimpleStat(label, value) { + ensureScopedStyle("portal-simple-stat", simpleStatCss); + + return h( + "div", + { className: "org-simple-stat", [scopeAttr]: "" }, + h("span", { className: "org-simple-label" }, label), + h("strong", { className: "org-simple-value" }, value), + ); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const scopeAttr = "data-ui-overview-card"; + const scopeSelector = `[${scopeAttr}]`; + const overviewCardCss = ` +${scopeSelector} .org-hero-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 1.5rem; + align-items: start; +} + +${scopeSelector} .org-summary { + margin: 0; + font-size: 1.05rem; + color: var(--text-main); +} + +${scopeSelector} .org-meta-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +${scopeSelector} .org-meta-item { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-meta-item:nth-child(even) { + background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-meta-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-meta-value { + font-size: 1rem; + font-weight: 600; + color: var(--primary-hover); +} + +${scopeSelector} .org-metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-hero-grid, + ${scopeSelector} .org-meta-row, + ${scopeSelector} .org-metric-grid { + grid-template-columns: 1fr; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.OverviewCard = function OverviewCard() { + const MetricCard = OrgPortal.componentFns.MetricCard; + const PanelCard = window.SharedUI.componentFns.PanelCard; + const readiness = getters.getAssetReadiness(); + const headquarters = portalData.org.headquarters || "ArmA Verse"; + const assetCount = store.getAssets().length; + const fleetCount = store.getFleet().length; + const funds = store.getFunds(); + const memberCount = store.getMembers().length; + const reputation = store.getReputation(); + ensureScopedStyle("portal-overview-card", overviewCardCss); + + return PanelCard({ + className: "org-span-12", + eyebrow: portalData.org.tag, + title: "Organization Overview", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-hero-grid" }, + h( + "div", + { className: "org-hero-copy" }, + h( + "p", + { className: "org-summary" }, + portalData.org.type, + " operating from ", + headquarters, + ". Treasury, fleet status, inventory, and roster management are surfaced here first.", + ), + h( + "div", + { className: "org-meta-row" }, + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Director", + ), + h( + "span", + { className: "org-meta-value" }, + getters.formatDisplayName(portalData.org.owner), + ), + ), + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Active Members", + ), + h( + "span", + { className: "org-meta-value" }, + `${memberCount} total`, + ), + ), + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Fleet Readiness", + ), + h( + "span", + { className: "org-meta-value" }, + readiness === null ? "N/A" : `${readiness}%`, + ), + ), + ), + ), + h( + "div", + { className: "org-metric-grid" }, + MetricCard( + "Org Funds", + getters.formatCurrency(funds), + "Organization treasury balance", + ), + MetricCard( + "Reputation", + reputation, + "Organization standing", + ), + MetricCard( + "Asset Lines", + assetCount, + "Tracked supply and equipment entries", + ), + MetricCard( + "Fleet Vehicles", + fleetCount, + "Tracked air, ground, and naval vehicles", + ), + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const getters = OrgPortal.getters; + const scopeAttr = "data-ui-fleet-card"; + const scopeSelector = `[${scopeAttr}]`; + const fleetCardCss = ` +${scopeSelector} .org-simple-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-simple-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-simple-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-simple-name { + color: var(--primary-hover); +} + +${scopeSelector} .org-simple-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-simple-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.FleetCard = function FleetCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const SimpleStat = OrgPortal.componentFns.SimpleStat; + const fleet = OrgPortal.store.getFleet(); + ensureScopedStyle("portal-fleet-card", fleetCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-7", + title: "Fleet", + subtitle: + "Individual vehicles with type, status, and overall damage.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-simple-list" }, + ...fleet.map((unit) => + h( + "article", + { className: "org-simple-row" }, + h( + "strong", + { className: "org-simple-name" }, + unit.name, + ), + h( + "div", + { className: "org-simple-meta" }, + SimpleStat( + "Type", + getters.formatVehicleType(unit.type), + ), + SimpleStat("Status", unit.status), + SimpleStat("Damage", unit.damage), + ), + ), + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle, createSignal } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-treasury-card"; + const scopeSelector = `[${scopeAttr}]`; + const [getTreasuryTab, setTreasuryTab] = createSignal("overview"); + const [getTreasuryMenuOpen, setTreasuryMenuOpen] = createSignal(false); + const treasuryCardCss = ` +${scopeSelector} .org-treasury-menu { + position: relative; +} + +${scopeSelector} .org-menu-btn { + width: 2.75rem; + height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + background: #f8fafc; + color: var(--text-muted); +} + +${scopeSelector} .org-menu-btn:hover { + color: var(--primary-hover); + border-color: rgb(148 163 184 / 0.65); +} + +${scopeSelector} .org-menu-btn svg { + width: 1.1rem; + height: 1.1rem; +} + +${scopeSelector} .org-menu-dropdown { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + min-width: 10.5rem; + padding: 0.45rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + box-shadow: 0 12px 28px rgb(15 23 42 / 0.12); + display: flex; + flex-direction: column; + gap: 0.35rem; + z-index: 5; +} + +${scopeSelector} .org-menu-option + .org-menu-option { + margin-left: 0; +} + +${scopeSelector} .org-menu-option { + width: 100%; + justify-content: flex-start; + background: transparent; + color: var(--text-main); + border: 1px solid transparent; +} + +${scopeSelector} .org-menu-option:hover { + background: #f8fafc; + border-color: rgb(148 163 184 / 0.35); +} + +${scopeSelector} .org-menu-option.is-active { + background: rgb(226 232 240 / 0.7); + color: var(--primary-hover); + border-color: rgb(148 163 184 / 0.35); +} + +${scopeSelector} .org-finance-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +${scopeSelector} .org-finance-meta > div { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +${scopeSelector} .org-meta-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-action-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +} + +${scopeSelector} .org-action-grid button + button { + margin-left: 0; +} + +${scopeSelector} .org-action-grid button { + width: 100%; +} + +${scopeSelector} .org-access-note { + margin: 0 0 1rem; + color: var(--text-muted); + font-size: 0.95rem; +} + +${scopeSelector} .org-credit-summary { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-credit-summary strong { + font-size: 1rem; +} + +${scopeSelector} .org-credit-summary span:last-child { + font-size: 0.92rem; + line-height: 1.45; +} + +${scopeSelector} .org-credit-lines-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +${scopeSelector} .org-treasury-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-credit-line-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-credit-line-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-credit-line-member { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +${scopeSelector} .org-credit-line-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-credit-line-empty { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; + color: var(--text-muted); +} + +@media (max-width: 960px) { + ${scopeSelector} .org-finance-meta { + grid-template-columns: 1fr; + } + + ${scopeSelector} .org-credit-line-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const creditLines = store.getCreditLines(); + const reputation = store.getReputation(); + const allowTreasuryActions = getters.canManageTreasury(); + const activeTab = getTreasuryTab(); + const isMenuOpen = getTreasuryMenuOpen(); + const activeCreditLabel = + creditLines.length === 1 + ? "1 active credit line" + : `${creditLines.length} active credit lines`; + ensureScopedStyle("portal-treasury-card", treasuryCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-5", + title: "Treasury", + subtitle: "Organization funds, reputation and payouts.", + headerExtras: h( + "div", + { className: "org-treasury-menu" }, + h( + "button", + { + type: "button", + className: "org-menu-btn", + title: "Treasury views", + "aria-label": "Treasury views", + onClick: () => setTreasuryMenuOpen((open) => !open), + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("line", { x1: "4", y1: "7", x2: "20", y2: "7" }), + h("line", { x1: "4", y1: "12", x2: "20", y2: "12" }), + h("line", { x1: "4", y1: "17", x2: "20", y2: "17" }), + ), + ), + isMenuOpen + ? h( + "div", + { className: "org-menu-dropdown" }, + h( + "button", + { + type: "button", + className: + activeTab === "overview" + ? "org-menu-option is-active" + : "org-menu-option", + onClick: () => { + setTreasuryTab("overview"); + setTreasuryMenuOpen(false); + }, + }, + "Overview", + ), + h( + "button", + { + type: "button", + className: + activeTab === "credit" + ? "org-menu-option is-active" + : "org-menu-option", + onClick: () => { + setTreasuryTab("credit"); + setTreasuryMenuOpen(false); + }, + }, + "Credit Lines", + ), + ) + : null, + ), + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-treasury-body" }, + activeTab === "credit" + ? creditLines.length > 0 + ? h( + "div", + { className: "org-credit-lines-list" }, + ...creditLines.map((line) => + h( + "article", + { className: "org-credit-line-row" }, + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Member", + ), + h("strong", null, line.member), + ), + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Amount", + ), + h( + "strong", + null, + getters.formatCurrency( + line.amount, + ), + ), + ), + ), + ), + ) + : h( + "div", + { className: "org-credit-line-empty" }, + "No active credit lines.", + ) + : h( + "div", + null, + h( + "div", + { className: "org-finance-meta" }, + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Funds", + ), + h( + "strong", + null, + getters.formatCurrency(store.getFunds()), + ), + ), + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Reputation", + ), + h("strong", null, `${reputation}`), + ), + ), + allowTreasuryActions + ? h( + "div", + { className: "org-action-grid" }, + h( + "button", + { + type: "button", + onClick: () => + actions.openModal("payroll"), + }, + "Run Payroll", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.openModal("transfer"), + }, + "Send Funds", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.openModal("credit"), + }, + "Credit Line", + ), + ) + : h( + "p", + { className: "org-access-note" }, + "Only the organization leader or CEO can manage treasury actions.", + ), + h( + "div", + { className: "org-credit-summary" }, + h( + "span", + { className: "org-meta-label" }, + "Credit Line Status", + ), + h("strong", null, activeCreditLabel), + h( + "span", + null, + creditLines.length > 0 + ? "Open the Credit Lines tab to review assigned members and amounts." + : "Assign a credit line to create the first approved member limit.", + ), + ), + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const getters = OrgPortal.getters; + const scopeAttr = "data-ui-assets-card"; + const scopeSelector = `[${scopeAttr}]`; + const assetsCardCss = ` +${scopeSelector} .org-simple-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-simple-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-simple-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-simple-name { + color: var(--primary-hover); +} + +${scopeSelector} .org-simple-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-simple-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.AssetsCard = function AssetsCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const SimpleStat = OrgPortal.componentFns.SimpleStat; + const assets = OrgPortal.store.getAssets(); + ensureScopedStyle("portal-assets-card", assetsCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-7", + title: "Assets", + subtitle: "Inventory supplies and equipment with quantity totals.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-simple-list" }, + ...assets.map((asset) => + h( + "article", + { className: "org-simple-row" }, + h( + "strong", + { className: "org-simple-name" }, + asset.name, + ), + h( + "div", + { className: "org-simple-meta" }, + SimpleStat( + "Type", + getters.formatAssetType(asset.type), + ), + SimpleStat("Quantity", asset.quantity), + ), + ), + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-members-card"; + const scopeSelector = `[${scopeAttr}]`; + const membersCardCss = ` +${scopeSelector} .org-name-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-name-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-name-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-name-row button { + margin-left: auto; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-name-row { + flex-direction: column; + align-items: flex-start; + } + + ${scopeSelector} .org-name-row button { + margin-left: 0; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.MembersCard = function MembersCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const members = store.getMembers(); + const allowMemberManagement = getters.canManageMembers(); + ensureScopedStyle("portal-members-card", membersCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-5", + title: "Members", + subtitle: + "Current roster listing. The organization owner and your own member entry cannot be removed.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-name-list" }, + ...members.map((member) => { + const canRemoveMember = + allowMemberManagement && + !getters.isProtectedMember(member); + + return h( + "article", + { className: "org-name-row" }, + h("strong", null, member.name), + canRemoveMember + ? h( + "button", + { + type: "button", + className: "org-danger-btn org-icon-btn", + title: `Remove ${member.name}`, + "aria-label": `Remove ${member.name}`, + onClick: () => + actions.removeMember(member), + }, + h( + "svg", + { + className: "org-icon", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("path", { d: "M9 3h6" }), + h("path", { d: "M4 7h16" }), + h("path", { d: "M6 7l1 13h10l1-13" }), + h("path", { d: "M10 11v6" }), + h("path", { d: "M14 11v6" }), + ), + ) + : null, + ); + }), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const scopeAttr = "data-ui-activity-card"; + const scopeSelector = `[${scopeAttr}]`; + const activityCardCss = ` +${scopeSelector} .org-activity-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-activity-row { + padding: 1rem; + border: 1px solid var(--border); + border-left: 3px solid #94a3b8; + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-activity-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); + border-left-color: #64748b; +} + +${scopeSelector} .org-activity-row p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .org-activity-time { + display: inline-block; + margin-bottom: 0.35rem; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.ActivityCard = function ActivityCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const activity = OrgPortal.store.getActivity(); + ensureScopedStyle("portal-activity-card", activityCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-6", + title: "Command Feed", + subtitle: "Recent organization-level actions and updates.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-activity-list" }, + ...activity.map((item) => + h( + "article", + { className: "org-activity-row" }, + h( + "span", + { className: "org-activity-time" }, + item.time, + ), + h("p", null, item.text), + ), + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-future-card"; + const ROADMAP = [ + { + name: "Contracts Board", + status: "Planned", + detail: "Track payouts, assignments, and claim approvals.", + }, + { + name: "Diplomacy", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Logistics Queue", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Permissions", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + ]; + const scopeSelector = `[${scopeAttr}]`; + const futureCardCss = ` +${scopeSelector} .org-roadmap-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + flex: 1; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-roadmap-card { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-roadmap-card:nth-child(4n + 2), +${scopeSelector} .org-roadmap-card:nth-child(4n + 3) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(100 116 139 / 0.4); +} + +${scopeSelector} .org-roadmap-card p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .org-list-tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: #e2e8f0; + color: var(--primary-hover); +} + +${scopeSelector} .org-roadmap-card:nth-child(4n + 2) .org-list-tag, +${scopeSelector} .org-roadmap-card:nth-child(4n + 3) .org-list-tag { + background: #cbd5e1; + color: #1e293b; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-roadmap-grid { + grid-template-columns: 1fr; + } + + ${scopeSelector} .org-roadmap-card:nth-child(4n + 3) { + background: #f8fafc; + border-color: var(--border); + } + + ${scopeSelector} .org-roadmap-card:nth-child(4n + 3) .org-list-tag { + background: #e2e8f0; + color: var(--primary-hover); + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.FutureCard = function FutureCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-future-card", futureCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-6", + title: "Expansion Slots", + subtitle: + "Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-roadmap-grid" }, + ...ROADMAP.map((item) => + h( + "article", + { className: "org-roadmap-card" }, + h("span", { className: "org-list-tag" }, item.status), + h("strong", null, item.name), + h("p", null, item.detail), + ), + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const getters = OrgPortal.getters; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-danger-card"; + const scopeSelector = `[${scopeAttr}]`; + const dangerCardCss = ` +${scopeSelector} { + border-color: #fecaca; + background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%); +} + +${scopeSelector} .org-danger-copy { + margin-bottom: 1rem; +} + +${scopeSelector} .org-danger-copy strong, +${scopeSelector} .org-danger-copy p { + display: block; +} + +${scopeSelector} .org-danger-copy p { + margin: 0.4rem 0 0; + color: var(--text-muted); +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.DangerCard = function DangerCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-danger-card", dangerCardCss); + + if (!getters.canDisbandOrg()) { + return null; + } + + return PanelCard({ + className: "org-span-12 org-danger-panel", + title: "Organization Controls", + subtitle: + "Leader-only actions for membership and permanent organization removal.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + null, + h( + "div", + { className: "org-danger-copy" }, + h("strong", null, "Disband organization"), + h( + "p", + null, + "This removes the organization and revokes access to the portal for all members.", + ), + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.openModal("disband"), + }, + "Disband Organization", + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const actions = OrgPortal.actions; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.ModalLayer = function ModalLayer() { + const Modal = window.SharedUI.componentFns.Modal; + const modal = store.getModal(); + if (!modal) { + return null; + } + + const members = store.getMembers(); + const memberSelectProps = + members.length === 0 ? { disabled: true } : {}; + + let title = ""; + let body = null; + + if (modal.type === "payroll") { + title = "Run Payroll"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Amount Per Member"), + h("input", { + id: "treasury-payroll-amount", + type: "number", + min: "1", + placeholder: "500", + autofocus: "true", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + onClick: () => { + if ( + actions.runPayroll( + actions.parseAmount( + actions.getInputValue( + "treasury-payroll-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Run Payroll", + ), + ), + ); + } else if (modal.type === "transfer") { + title = "Send Funds"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Member"), + h( + "select", + { + id: "treasury-transfer-member", + ...memberSelectProps, + }, + ...members.map((member) => + h("option", { value: member.name }, member.name), + ), + ), + ), + h( + "div", + null, + h("label", null, "Amount"), + h("input", { + id: "treasury-transfer-amount", + type: "number", + min: "1", + placeholder: "1500", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...memberSelectProps, + onClick: () => { + if ( + actions.sendFundsToMember( + String( + actions.getInputValue( + "treasury-transfer-member", + ) || "", + ), + actions.parseAmount( + actions.getInputValue( + "treasury-transfer-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Send Funds", + ), + ), + ); + } else if (modal.type === "credit") { + title = "Assign Credit Line"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Member"), + h( + "select", + { id: "treasury-credit-member", ...memberSelectProps }, + ...members.map((member) => + h("option", { value: member.uid }, member.name), + ), + ), + ), + h( + "div", + null, + h("label", null, "Credit Amount"), + h("input", { + id: "treasury-credit-amount", + type: "number", + min: "1", + placeholder: "5000", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...memberSelectProps, + onClick: () => { + if ( + actions.grantCreditLine( + String( + actions.getInputValue( + "treasury-credit-member", + ) || "", + ), + actions.parseAmount( + actions.getInputValue( + "treasury-credit-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Assign Credit Line", + ), + ), + ); + } else if (modal.type === "disband") { + title = "Disband Organization"; + body = h( + "div", + { className: "app-modal-danger" }, + h( + "p", + null, + "This action is permanent. Disband ", + portalData.org.name, + "?", + ), + h( + "div", + { className: "app-modal-danger-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.disbandOrganization(), + }, + "Confirm Disband", + ), + ), + ); + } else if (modal.type === "leave") { + title = "Leave Organization"; + body = h( + "div", + { className: "app-modal-danger" }, + h( + "p", + null, + "Leave ", + portalData.org.name, + " and return to the default organization?", + ), + h( + "div", + { className: "app-modal-danger-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.leaveOrganization(), + }, + "Confirm Leave", + ), + ), + ); + } + + return Modal({ + title, + body, + onClose: () => actions.closeModal(), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const registryStore = window.RegistryApp.store; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.DisbandedView = function DisbandedView() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + + return PanelCard({ + className: "org-span-12 org-empty-state", + eyebrow: "Organization Removed", + title: portalData.org.name, + body: h( + "div", + null, + h( + "p", + { className: "org-summary" }, + "This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview.", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => registryStore.setView("home"), + }, + "Return to Registry", + ), + ), + }); + }; +})(); + +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData, session } = OrgPortal.data; + const store = OrgPortal.store; + const portalViewScope = "[data-ui-portal-view]"; + + ensureScopedStyle( + "portal-view", + ` + ${portalViewScope} { + --org-row-card-max-height: 36rem; + } + + ${portalViewScope} .org-toast-stack { + position: fixed; + top: 1.5rem; + right: 2rem; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.75rem; + pointer-events: none; + } + + ${portalViewScope} .org-toast { + max-width: 24rem; + padding: 0.9rem 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + box-shadow: 0 12px 28px rgb(15 23 42 / 0.14); + font-size: 0.92rem; + pointer-events: auto; + } + + ${portalViewScope} .org-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; + } + + ${portalViewScope} .org-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; + } + + ${portalViewScope} .org-dashboard-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 1.5rem; + align-items: stretch; + } + + ${portalViewScope} .org-panel { + margin-bottom: 0; + text-align: left; + } + + ${portalViewScope} .org-scroll-panel { + display: flex; + flex-direction: column; + min-height: 0; + max-height: var(--org-row-card-max-height); + overflow: hidden; + } + + ${portalViewScope} .org-island-root { + display: flex; + align-self: stretch; + min-height: 0; + min-width: 0; + } + + ${portalViewScope} .org-island-root > .org-panel { + height: 100%; + width: 100%; + } + + ${portalViewScope} .org-span-12 { + grid-column: span 12; + } + + ${portalViewScope} .org-span-7 { + grid-column: span 7; + } + + ${portalViewScope} .org-span-6 { + grid-column: span 6; + } + + ${portalViewScope} .org-span-5 { + grid-column: span 5; + } + + @media (max-width: 960px) { + ${portalViewScope} .org-toast-stack { + top: 1rem; + right: 1rem; + left: 1rem; + } + + ${portalViewScope} .org-toast { + max-width: none; + } + + ${portalViewScope} .org-span-12, + ${portalViewScope} .org-span-7, + ${portalViewScope} .org-span-6, + ${portalViewScope} .org-span-5 { + grid-column: span 12; + } + + ${portalViewScope} .org-scroll-panel { + max-height: none; + } + + } + `, + ); + + OrgPortal.components = OrgPortal.components || {}; + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.TreasuryNoticeLayer = + function TreasuryNoticeLayer() { + const treasuryNotice = store.getTreasuryNotice(); + if (!treasuryNotice.text) { + return null; + } + + return h( + "div", + { className: "org-toast-stack" }, + h( + "div", + { + className: + treasuryNotice.type === "error" + ? "org-toast is-error" + : "org-toast is-success", + }, + treasuryNotice.text, + ), + ); + }; + + OrgPortal.components.App = function App() { + const Hero = window.SharedUI.componentFns.Hero; + const Footer = window.SharedUI.componentFns.Footer; + const FutureCard = OrgPortal.componentFns.FutureCard; + const DangerCard = OrgPortal.componentFns.DangerCard; + const DisbandedView = OrgPortal.componentFns.DisbandedView; + const footerSections = [ + { + title: "Organization Controls", + items: [ + "Roster Management", + "Fleet Assignment", + "Treasury Permissions", + "Asset Registry", + ], + }, + { + title: "Planned Extensions", + items: [ + "Contracts Board", + "Diplomacy Layer", + "Procurement Queue", + "Reputation History", + ], + }, + ]; + if (store.getOrgDisbanded()) { + return h( + "main", + { "data-ui-portal-view": "" }, + h( + "div", + { className: "container" }, + h( + "div", + { className: "org-dashboard-grid" }, + Hero({ + kicker: portalData.org.tag, + title: portalData.org.name, + subtitle: "Player organization command portal", + meta: `${session.actorName} - ${session.role}`, + }), + DisbandedView(), + ), + ), + h("div", { id: "org-portal-modal-root" }), + Footer({ sections: footerSections }), + ); + } + + return h( + "main", + { "data-ui-portal-view": "" }, + h("div", { id: "org-portal-toast-root" }), + h( + "div", + { className: "container" }, + h( + "div", + { className: "org-dashboard-grid" }, + Hero({ + kicker: portalData.org.tag, + title: portalData.org.name, + subtitle: "Player organization command portal", + meta: `${session.actorName} - ${session.role}`, + }), + h("div", { + className: "org-island-root org-span-12", + id: "org-overview-card-root", + }), + h("div", { + className: "org-island-root org-span-7", + id: "org-fleet-card-root", + }), + h("div", { + className: "org-island-root org-span-5", + id: "org-treasury-card-root", + }), + h("div", { + className: "org-island-root org-span-5", + id: "org-members-card-root", + }), + h("div", { + className: "org-island-root org-span-7", + id: "org-assets-card-root", + }), + h("div", { + className: "org-island-root org-span-6", + id: "org-activity-card-root", + }), + FutureCard(), + DangerCard(), + ), + ), + h("div", { id: "org-portal-modal-root" }), + Footer({ sections: footerSections }), + ); + }; +})(); + +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const store = RegistryApp.store; + const bridge = RegistryApp.bridge; + const scopeAttr = "data-ui-registration-view"; + const scopeSelector = `[${scopeAttr}]`; + const registrationViewCss = ` +${scopeSelector} { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: center; + width: 100%; +} + +${scopeSelector} .info-panel { + text-align: left; + padding: 1rem; +} + +${scopeSelector} .create-feature-list { + text-align: left; + margin-top: 1.5rem; + list-style-type: none; + padding: 0; +} + +${scopeSelector} .create-feature-item { + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +${scopeSelector} .create-feature-icon { + width: 1.2rem; + height: 1.2rem; + flex-shrink: 0; +} + +${scopeSelector} .price-tag { + margin-top: 2rem; + padding: 1rem; + background: var(--bg-app); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +${scopeSelector} .price-label { + display: block; + font-size: 0.9rem; + color: var(--text-muted); +} + +${scopeSelector} .price-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--primary); +} + +${scopeSelector} .form-panel { + margin: 0; +} + +${scopeSelector} .app-form { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; +} + +${scopeSelector} .app-form label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; +} + +${scopeSelector} .app-form input, +${scopeSelector} .app-form select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s; +} + +${scopeSelector} .app-form input:focus, +${scopeSelector} .app-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1); +} + +${scopeSelector} .form-actions { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +${scopeSelector} .submit-btn { + width: 100%; +} + +${scopeSelector} .cancel-link { + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + text-decoration: underline; +} + +${scopeSelector} .cancel-link:hover { + color: var(--primary); +} + +${scopeSelector} .form-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; +} + +${scopeSelector} .form-feedback.is-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +@media (max-width: 960px) { + ${scopeSelector} { + grid-template-columns: 1fr; + } +} +`; + + RegistryApp.componentFns = RegistryApp.componentFns || {}; + + RegistryApp.componentFns.RegistrationView = function RegistrationView() { + const isCreating = store.getIsCreating(); + const createError = store.getCreateError(); + ensureScopedStyle("main-registration-view", registrationViewCss); + + const handleCreate = () => { + const data = { + orgName: String( + document.getElementById("org-create-name")?.value || "", + ).trim(), + type: String( + document.getElementById("org-create-type")?.value || "", + ), + }; + + if (!bridge || typeof bridge.requestCreateOrg !== "function") { + store.failCreate("Registration bridge is not available."); + return; + } + + bridge.requestCreateOrg(data); + }; + + return h( + "div", + { className: "split-container", [scopeAttr]: "" }, + h( + "div", + { className: "info-panel" }, + h("h2", null, "Registration Details"), + h( + "p", + null, + "Complete the form to add your organization to the Global Organization Registry.", + ), + h( + "ul", + { className: "create-feature-list" }, + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Official Organization Designator", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Secure Comms Channel", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Deployment Roster Access", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "After-Action Report Tools", + ), + ), + h( + "div", + { className: "price-tag" }, + h("span", { className: "price-label" }, "Registration Fee"), + h("span", { className: "price-value" }, "$50,000"), + ), + ), + h( + "div", + { className: "form-panel card" }, + h("h2", null, "Organization Registration"), + h( + "div", + { className: "app-form" }, + h( + "div", + null, + h("label", null, "Organization Name"), + h("input", { + id: "org-create-name", + type: "text", + placeholder: "e.g. Task Force 141", + }), + ), + h( + "div", + null, + h("label", null, "Organization Type"), + h( + "select", + { id: "org-create-type" }, + h( + "option", + { value: "infantry" }, + "Infantry / Milsim", + ), + h("option", { value: "aviation" }, "Aviation Wing"), + h( + "option", + { value: "pmc" }, + "Private Military Company", + ), + h( + "option", + { value: "support" }, + "Logistics & Support", + ), + ), + ), + h( + "div", + { className: "form-actions" }, + createError + ? h( + "div", + { className: "form-feedback is-error" }, + createError, + ) + : null, + h( + "button", + { + type: "button", + className: "submit-btn", + disabled: isCreating, + onClick: handleCreate, + }, + isCreating + ? "Submitting Registration..." + : "Submit Registration", + ), + h( + "span", + { + className: "cancel-link", + onClick: () => store.setView("home"), + }, + "Cancel / Return to Main", + ), + ), + ), + ), + ); + }; +})(); + +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const store = RegistryApp.store; + const bridge = RegistryApp.bridge; + const scopeAttr = "data-ui-home-view"; + const scopeSelector = `[${scopeAttr}]`; + const homeViewCss = ` +${scopeSelector} { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +${scopeSelector} .home-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +@media (max-width: 960px) { + ${scopeSelector} { + grid-template-columns: 1fr; + } +} +`; + + RegistryApp.componentFns = RegistryApp.componentFns || {}; + + RegistryApp.componentFns.HomeView = function HomeView() { + const isAuthenticating = store.getIsAuthenticating(); + const loginError = store.getLoginError(); + ensureScopedStyle("main-home-view", homeViewCss); + + return h( + "div", + { className: "content", [scopeAttr]: "" }, + h( + "div", + { className: "card" }, + h("h2", null, "Create Organization"), + h( + "p", + null, + "Establish your Task Force, PMC, or Milsim unit with the Global Organization Network. Receive your official unit designator and TO&E authorization instantly.", + ), + h( + "button", + { onClick: () => store.setView("create") }, + "Register", + ), + ), + h( + "div", + { className: "card" }, + h("h2", null, "Organization Portal"), + h( + "p", + null, + "Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink.", + ), + loginError + ? h("div", { className: "home-feedback" }, loginError) + : null, + h( + "button", + { + disabled: isAuthenticating, + onClick: () => { + if (!bridge) { + store.failLogin( + "Login bridge is not available.", + ); + return; + } + + bridge.requestLogin({}); + }, + }, + isAuthenticating ? "Opening Portal..." : "Login", + ), + ), + ); + }; +})(); + +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + const store = RegistryApp.store; + + RegistryApp.components = RegistryApp.components || {}; + + RegistryApp.components.App = function App() { + const Navbar = window.SharedUI.componentFns.Navbar; + const Header = window.SharedUI.componentFns.Header; + const Footer = window.SharedUI.componentFns.Footer; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const HomeView = RegistryApp.componentFns.HomeView; + const RegistrationView = RegistryApp.componentFns.RegistrationView; + const PortalApp = + window.OrgPortal && window.OrgPortal.components + ? window.OrgPortal.components.App + : null; + + const view = store.getView(); + const portalGetters = + window.OrgPortal && window.OrgPortal.getters + ? window.OrgPortal.getters + : null; + const portalActions = + window.OrgPortal && window.OrgPortal.actions + ? window.OrgPortal.actions + : null; + const viewLabel = + view === "create" + ? "Organization Registration" + : view === "portal" + ? "Organization Portal" + : "Entry Hub"; + const footerSections = [ + { + title: "Registry Resources", + items: [ + "Registration Guidelines", + "Tax & Fee Schedule", + "Legal Compliance", + "Trademark Database", + ], + }, + { + title: "Bureau Support", + items: [ + "Office: Sector 7 Admin Block", + "Hours: 0800 - 1600 (GST)", + "Helpdesk: 555-01-REGISTRY", + "support@org-bureau.gov", + ], + }, + ]; + + function closeRegistry() { + if ( + RegistryApp.bridge && + typeof RegistryApp.bridge.close === "function" + ) { + RegistryApp.bridge.close({}); + return; + } + + store.setView("home"); + } + + if (view === "portal" && PortalApp) { + const canLeaveOrg = + portalGetters && + typeof portalGetters.canLeaveOrg === "function" && + portalGetters.canLeaveOrg(); + + return h( + "div", + { className: "app-shell" }, + WindowTitleBar({ + kicker: "ORBIS Workspace", + title: "Global Organization Network", + onClose: closeRegistry, + closeLabel: "Close organization interface", + }), + Navbar({ + title: "Global Organization Network", + viewLabel, + actionLabel: canLeaveOrg ? "Leave Organization" : "", + onAction: + canLeaveOrg && + portalActions && + typeof portalActions.openModal === "function" + ? () => portalActions.openModal("leave") + : null, + }), + h("div", { id: "org-portal-frame-root" }), + ); + } + + let mainContent; + if (view === "home") { + mainContent = HomeView(); + } else if (view === "create") { + mainContent = RegistrationView(); + } + + return h( + "div", + { className: "app-shell" }, + WindowTitleBar({ + kicker: "ORBIS Workspace", + title: "Global Organization Network", + onClose: closeRegistry, + closeLabel: "Close organization interface", + }), + h( + "main", + null, + Navbar({ + title: "Global Organization Network", + viewLabel, + }), + h( + "div", + { className: "container" }, + Header({ + title: "Global Organization Network", + onTitleClick: () => store.setView("home"), + }), + mainContent, + ), + Footer({ sections: footerSections }), + ), + ); + }; +})(); + +(function () { + const ForgeWebUI = window.ForgeWebUI; + const RegistryApp = window.RegistryApp; + const OrgPortal = window.OrgPortal; + const islandDefinitions = [ + { + id: "org-portal-frame-root", + preserveScroll: true, + render: () => OrgPortal.components.App(), + }, + { + id: "org-portal-toast-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.TreasuryNoticeLayer(), + }, + { + id: "org-overview-card-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.OverviewCard(), + }, + { + id: "org-fleet-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.FleetCard(), + }, + { + id: "org-treasury-card-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.TreasuryCard(), + }, + { + id: "org-members-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.MembersCard(), + }, + { + id: "org-assets-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.AssetsCard(), + }, + { + id: "org-activity-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.ActivityCard(), + }, + { + id: "org-portal-modal-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.ModalLayer(), + }, + ]; + + function createIslandManager() { + const mounts = new Map(); + + function sync() { + islandDefinitions.forEach((definition) => { + const container = document.getElementById(definition.id); + const current = mounts.get(definition.id); + + if (!container) { + if (current) { + current.handle.dispose(); + mounts.delete(definition.id); + } + return; + } + + if (current && current.container === container) { + return; + } + + if (current) { + current.handle.dispose(); + } + + const handle = ForgeWebUI.mount(container, definition.render, { + preserveScroll: definition.preserveScroll, + }); + mounts.set(definition.id, { + container, + handle, + }); + }); + } + + return { + sync, + }; + } + + const app = ForgeWebUI.createApp({ + name: "org", + root: "#app", + setup({ root }) { + const islandManager = createIslandManager(); + + ForgeWebUI.mount(root, () => RegistryApp.components.App(), { + preserveScroll: false, + }); + RegistryApp.bridge.ready({ loaded: true }); + + ForgeWebUI.effect(() => { + RegistryApp.store.getView(); + + requestAnimationFrame(() => { + islandManager.sync(); + }); + }); + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/org/ui/_site/portal/actions.js b/arma/client/addons/org/ui/_site/portal/actions.js deleted file mode 100644 index 2efa6e1..0000000 --- a/arma/client/addons/org/ui/_site/portal/actions.js +++ /dev/null @@ -1,15 +0,0 @@ -(function () { - const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); - const { portalData } = OrgPortal.data; - const store = OrgPortal.store; - const getters = OrgPortal.getters; - const registryStore = window.RegistryApp.store; - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - OrgPortal.actions = SharedLogic.createPortalActions({ - portalData, - store, - getters, - registryStore, - }); -})(); diff --git a/arma/client/addons/org/ui/_site/portal/getters.js b/arma/client/addons/org/ui/_site/portal/getters.js deleted file mode 100644 index 61d2660..0000000 --- a/arma/client/addons/org/ui/_site/portal/getters.js +++ /dev/null @@ -1,10 +0,0 @@ -(function () { - const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); - const { portalData, session } = OrgPortal.data; - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - OrgPortal.getters = SharedLogic.createPortalGetters({ - portalData, - session, - }); -})(); diff --git a/arma/client/addons/org/ui/_site/portal/useStore.js b/arma/client/addons/org/ui/_site/portal/useStore.js deleted file mode 100644 index 5f2711e..0000000 --- a/arma/client/addons/org/ui/_site/portal/useStore.js +++ /dev/null @@ -1,11 +0,0 @@ -(function () { - const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); - const { createSignal } = window.RegistryApp.runtime; - const { portalData } = OrgPortal.data; - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - OrgPortal.store = SharedLogic.createPortalStore({ - createSignal, - portalData, - }); -})(); diff --git a/arma/client/addons/org/ui/_site/runtime.js b/arma/client/addons/org/ui/_site/runtime.js deleted file mode 100644 index 73b768f..0000000 --- a/arma/client/addons/org/ui/_site/runtime.js +++ /dev/null @@ -1,248 +0,0 @@ -(function () { - const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); - const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); - - const SVG_NS = "http://www.w3.org/2000/svg"; - const SVG_TAGS = new Set([ - "svg", - "path", - "circle", - "rect", - "line", - "polyline", - "polygon", - "g", - "defs", - "use", - "text", - "tspan", - "clipPath", - "mask", - ]); - - function appendChild(el, child) { - if (child === null || child === undefined || child === false) { - return; - } - - if (Array.isArray(child)) { - child.forEach((entry) => appendChild(el, entry)); - return; - } - - if (typeof child === "string" || typeof child === "number") { - el.appendChild(document.createTextNode(String(child))); - return; - } - - if (child instanceof Node) { - el.appendChild(child); - } - } - - function h(tag, props = {}, ...children) { - const isSvg = SVG_TAGS.has(tag); - const el = isSvg - ? document.createElementNS(SVG_NS, tag) - : document.createElement(tag); - - if (props) { - Object.entries(props).forEach(([key, value]) => { - if (key.startsWith("on") && typeof value === "function") { - el.addEventListener(key.substring(2).toLowerCase(), value); - return; - } - - if (key === "className") { - if (isSvg) { - el.setAttribute("class", value); - } else { - el.className = value; - } - return; - } - - if (key === "style" && typeof value === "object") { - Object.assign(el.style, value); - return; - } - - if (key === "value" && "value" in el) { - el.value = value; - return; - } - - if (key === "checked" && "checked" in el) { - el.checked = Boolean(value); - return; - } - - if (typeof value === "boolean") { - if (value) { - el.setAttribute(key, ""); - } else { - el.removeAttribute(key); - } - return; - } - - if (value === null || value === undefined) { - el.removeAttribute(key); - return; - } - - el.setAttribute(key, value); - }); - } - - children.forEach((child) => appendChild(el, child)); - return el; - } - - let rootContainer = null; - let rootComponent = null; - const injectedStyles = new Set(); - let rerenderQueued = false; - - 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 captureViewportScroll(container) { - if (!container) { - return { scrollTop: 0, scrollLeft: 0 }; - } - - const viewport = container.querySelector("main"); - if (!viewport) { - return { - scrollTop: window.scrollY || window.pageYOffset || 0, - scrollLeft: window.scrollX || window.pageXOffset || 0, - }; - } - - return { - scrollTop: viewport.scrollTop, - scrollLeft: viewport.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 restoreViewportScroll(container, viewportScroll) { - if (!container || !viewportScroll) { - return; - } - - const viewport = container.querySelector("main"); - if (!viewport) { - window.scrollTo( - Number(viewportScroll.scrollLeft || 0), - Number(viewportScroll.scrollTop || 0), - ); - return; - } - - viewport.scrollTop = Number(viewportScroll.scrollTop || 0); - viewport.scrollLeft = Number(viewportScroll.scrollLeft || 0); - } - - function render(component, container) { - rootContainer = container; - rootComponent = component; - rerender(); - } - - function flushRerender() { - if (!rootContainer || !rootComponent) { - return; - } - - const viewportScroll = captureViewportScroll(rootContainer); - const scrollState = captureScrollState(rootContainer); - rootContainer.innerHTML = ""; - rootContainer.appendChild(rootComponent()); - requestAnimationFrame(() => { - restoreScrollState(rootContainer, scrollState); - restoreViewportScroll(rootContainer, viewportScroll); - }); - } - - function rerender() { - if (rerenderQueued) { - return; - } - - rerenderQueued = true; - requestAnimationFrame(() => { - rerenderQueued = false; - flushRerender(); - }); - } - - function ensureScopedStyle(id, cssText) { - if (!id || !cssText || injectedStyles.has(id)) { - return; - } - - const style = document.createElement("style"); - style.setAttribute("data-ui-style", id); - style.textContent = cssText; - document.head.appendChild(style); - injectedStyles.add(id); - } - - function createSignal(initialValue) { - let value = initialValue; - - const getValue = () => value; - const setValue = (newValue) => { - value = typeof newValue === "function" ? newValue(value) : newValue; - rerender(); - }; - - return [getValue, setValue]; - } - - const runtime = { - h, - render, - rerender, - createSignal, - ensureScopedStyle, - }; - - RegistryApp.runtime = runtime; - OrgPortal.runtime = runtime; - window.AppRuntime = runtime; -})(); diff --git a/arma/client/addons/org/ui/_site/useRegistryStore.js b/arma/client/addons/org/ui/_site/useRegistryStore.js deleted file mode 100644 index ae2a830..0000000 --- a/arma/client/addons/org/ui/_site/useRegistryStore.js +++ /dev/null @@ -1,25 +0,0 @@ -(function () { - const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); - const { createSignal } = RegistryApp.runtime; - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - RegistryApp.store = SharedLogic.createRegistryStore({ - createSignal, - onHydratePortal(payload) { - const OrgPortal = window.OrgPortal; - const portalData = payload?.portalData; - const session = payload?.session; - - if (!OrgPortal || !portalData || !session) { - return false; - } - - OrgPortal.data.applyLoginPayload(payload); - OrgPortal.store.hydrateFromPayload(payload); - if (RegistryApp.store.getView() !== "portal") { - RegistryApp.store.setView("portal"); - } - return true; - }, - }); -})(); diff --git a/arma/client/addons/org/ui/src/bootstrap.js b/arma/client/addons/org/ui/src/bootstrap.js new file mode 100644 index 0000000..474ba30 --- /dev/null +++ b/arma/client/addons/org/ui/src/bootstrap.js @@ -0,0 +1,114 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const RegistryApp = window.RegistryApp; + const OrgPortal = window.OrgPortal; + const islandDefinitions = [ + { + id: "org-portal-frame-root", + preserveScroll: true, + render: () => OrgPortal.components.App(), + }, + { + id: "org-portal-toast-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.TreasuryNoticeLayer(), + }, + { + id: "org-overview-card-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.OverviewCard(), + }, + { + id: "org-fleet-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.FleetCard(), + }, + { + id: "org-treasury-card-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.TreasuryCard(), + }, + { + id: "org-members-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.MembersCard(), + }, + { + id: "org-assets-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.AssetsCard(), + }, + { + id: "org-activity-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.ActivityCard(), + }, + { + id: "org-portal-modal-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.ModalLayer(), + }, + ]; + + function createIslandManager() { + const mounts = new Map(); + + function sync() { + islandDefinitions.forEach((definition) => { + const container = document.getElementById(definition.id); + const current = mounts.get(definition.id); + + if (!container) { + if (current) { + current.handle.dispose(); + mounts.delete(definition.id); + } + return; + } + + if (current && current.container === container) { + return; + } + + if (current) { + current.handle.dispose(); + } + + const handle = ForgeWebUI.mount(container, definition.render, { + preserveScroll: definition.preserveScroll, + }); + mounts.set(definition.id, { + container, + handle, + }); + }); + } + + return { + sync, + }; + } + + const app = ForgeWebUI.createApp({ + name: "org", + root: "#app", + setup({ root }) { + const islandManager = createIslandManager(); + + ForgeWebUI.mount(root, () => RegistryApp.components.App(), { + preserveScroll: false, + }); + RegistryApp.bridge.ready({ loaded: true }); + + ForgeWebUI.effect(() => { + RegistryApp.store.getView(); + + requestAnimationFrame(() => { + islandManager.sync(); + }); + }); + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/org/ui/src/bridge.js b/arma/client/addons/org/ui/src/bridge.js new file mode 100644 index 0000000..cfa10ae --- /dev/null +++ b/arma/client/addons/org/ui/src/bridge.js @@ -0,0 +1,229 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const store = RegistryApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "org::close", + globalName: "ForgeBridge", + readyEvent: "org::ready", + }); + + function sendEvent(event, data) { + return bridge.send(event, data); + } + + function requestLogin(credentials) { + store.startLogin(); + + const sent = sendEvent("org::login::request", credentials); + if (sent) { + return; + } + + store.failLogin("Arma login bridge is unavailable."); + } + + function requestCreateOrg(registration) { + store.startCreate(); + + const sent = sendEvent("org::create::request", registration); + if (sent) { + return; + } + + store.failCreate("Arma registration bridge is unavailable."); + } + + function requestDisbandOrg() { + const sent = sendEvent("org::disband::request", {}); + if (sent) { + return; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma disband bridge is unavailable.", + ); + } + } + + function requestLeaveOrg() { + const sent = sendEvent("org::leave::request", {}); + if (sent) { + return; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma leave bridge is unavailable.", + ); + } + } + + function requestCreditLine(payload) { + const sent = sendEvent("org::credit::request", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma credit line bridge is unavailable.", + ); + } + + return false; + } + + bridge.on("org::login::success", (payloadData) => { + store.completeLogin(payloadData); + }); + + bridge.on("org::login::failure", (payloadData) => { + store.failLogin(payloadData.message || "Authentication failed."); + }); + + bridge.on("org::create::success", (payloadData) => { + store.completeCreate(payloadData); + }); + + bridge.on("org::create::failure", (payloadData) => { + store.failCreate( + payloadData.message || "Organization registration failed.", + ); + }); + + bridge.on("org::sync", (payloadData) => { + if (store && typeof store.hydratePortal === "function") { + store.hydratePortal(payloadData); + } + }); + + bridge.on("org::credit::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "success", + payloadData.message || "Credit line assigned.", + ); + } + }); + + bridge.on("org::credit::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to assign credit line.", + ); + } + }); + + bridge.on("org::member::creditUpdated", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (!OrgPortal || !OrgPortal.store) { + return; + } + + OrgPortal.store.setCreditLines((currentLines) => { + const nextLine = { + amount: payloadData.amount || 0, + member: payloadData.memberName || "", + uid: payloadData.memberUid || "", + }; + const matchIndex = currentLines.findIndex( + (line) => line.uid === nextLine.uid, + ); + + if (matchIndex === -1) { + return [...currentLines, nextLine]; + } + + return currentLines.map((line, index) => + index === matchIndex ? nextLine : line, + ); + }); + }); + + bridge.on("org::disband::success", () => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + OrgPortal.store.setOrgDisbanded(true); + } + }); + + bridge.on("org::disband::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Organization disbanding failed.", + ); + } + }); + + bridge.on("org::leave::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + store.failLogin( + payloadData.message || "You have left the organization.", + ); + store.setView("home"); + }); + + bridge.on("org::leave::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to leave the organization.", + ); + } + }); + + bridge.on("org::portal::revoked", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + store.failLogin( + payloadData.message || + "Organization access is no longer available.", + ); + store.setView("home"); + }); + + RegistryApp.bridge = { + close: bridge.close, + ready: bridge.ready, + receive: bridge.receive, + requestLogin, + requestCreateOrg, + requestDisbandOrg, + requestLeaveOrg, + requestCreditLine, + sendEvent, + }; +})(); diff --git a/arma/client/addons/org/ui/_site/components/AppShell.js b/arma/client/addons/org/ui/src/components/AppShell.js similarity index 66% rename from arma/client/addons/org/ui/_site/components/AppShell.js rename to arma/client/addons/org/ui/src/components/AppShell.js index dc3e738..7b52bfb 100644 --- a/arma/client/addons/org/ui/_site/components/AppShell.js +++ b/arma/client/addons/org/ui/src/components/AppShell.js @@ -5,64 +5,11 @@ RegistryApp.components = RegistryApp.components || {}; - function WindowTitleBar({ title, onClose }) { - return h( - "div", - { className: "window-titlebar" }, - h( - "div", - { className: "window-titlebar-brand" }, - h( - "span", - { className: "window-titlebar-kicker" }, - "ORBIS Workspace", - ), - h("span", { className: "window-titlebar-title" }, title), - ), - h( - "div", - { className: "window-titlebar-controls" }, - h( - "button", - { - type: "button", - className: "window-control-btn", - disabled: true, - title: "Minimize unavailable", - "aria-label": "Minimize unavailable", - }, - "-", - ), - h( - "button", - { - type: "button", - className: "window-control-btn", - disabled: true, - title: "Maximize unavailable", - "aria-label": "Maximize unavailable", - }, - "[ ]", - ), - h( - "button", - { - type: "button", - className: "window-control-btn is-close", - onClick: onClose, - title: "Close", - "aria-label": "Close organization interface", - }, - "X", - ), - ), - ); - } - RegistryApp.components.App = function App() { const Navbar = window.SharedUI.componentFns.Navbar; const Header = window.SharedUI.componentFns.Header; const Footer = window.SharedUI.componentFns.Footer; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; const HomeView = RegistryApp.componentFns.HomeView; const RegistrationView = RegistryApp.componentFns.RegistrationView; const PortalApp = @@ -108,15 +55,10 @@ function closeRegistry() { if ( - typeof A3API !== "undefined" && - typeof A3API.SendAlert === "function" + RegistryApp.bridge && + typeof RegistryApp.bridge.close === "function" ) { - A3API.SendAlert( - JSON.stringify({ - event: "org::close", - data: {}, - }), - ); + RegistryApp.bridge.close({}); return; } @@ -133,8 +75,10 @@ "div", { className: "app-shell" }, WindowTitleBar({ + kicker: "ORBIS Workspace", title: "Global Organization Network", onClose: closeRegistry, + closeLabel: "Close organization interface", }), Navbar({ title: "Global Organization Network", @@ -147,7 +91,7 @@ ? () => portalActions.openModal("leave") : null, }), - PortalApp(), + h("div", { id: "org-portal-frame-root" }), ); } @@ -162,8 +106,10 @@ "div", { className: "app-shell" }, WindowTitleBar({ + kicker: "ORBIS Workspace", title: "Global Organization Network", onClose: closeRegistry, + closeLabel: "Close organization interface", }), h( "main", diff --git a/arma/client/addons/org/ui/_site/components/footer.js b/arma/client/addons/org/ui/src/components/footer.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/footer.js rename to arma/client/addons/org/ui/src/components/footer.js diff --git a/arma/client/addons/org/ui/_site/components/header.js b/arma/client/addons/org/ui/src/components/header.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/header.js rename to arma/client/addons/org/ui/src/components/header.js diff --git a/arma/client/addons/org/ui/_site/components/hero.js b/arma/client/addons/org/ui/src/components/hero.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/hero.js rename to arma/client/addons/org/ui/src/components/hero.js diff --git a/arma/client/addons/org/ui/_site/components/modal.js b/arma/client/addons/org/ui/src/components/modal.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/modal.js rename to arma/client/addons/org/ui/src/components/modal.js diff --git a/arma/client/addons/org/ui/_site/components/navbar.js b/arma/client/addons/org/ui/src/components/navbar.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/navbar.js rename to arma/client/addons/org/ui/src/components/navbar.js diff --git a/arma/client/addons/org/ui/_site/components/panelCard.js b/arma/client/addons/org/ui/src/components/panelCard.js similarity index 88% rename from arma/client/addons/org/ui/_site/components/panelCard.js rename to arma/client/addons/org/ui/src/components/panelCard.js index b927c98..02b6cae 100644 --- a/arma/client/addons/org/ui/_site/components/panelCard.js +++ b/arma/client/addons/org/ui/src/components/panelCard.js @@ -5,6 +5,13 @@ const scopeAttr = "data-ui-panel-card"; const scopeSelector = `[${scopeAttr}]`; const panelCardCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + ${scopeSelector} .org-panel-head { display: flex; align-items: flex-start; @@ -13,6 +20,13 @@ ${scopeSelector} .org-panel-head { margin-bottom: 1.5rem; } +${scopeSelector} .org-panel-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + ${scopeSelector} .org-eyebrow { font-size: 0.8rem; font-weight: 700; @@ -77,7 +91,7 @@ ${scopeSelector} .org-panel-subtitle { ), headerExtras, ), - body, + h("div", { className: "org-panel-body" }, body), ); }; })(); diff --git a/arma/client/addons/org/ui/_site/components/portal/activityCard.js b/arma/client/addons/org/ui/src/components/portal/activityCard.js similarity index 95% rename from arma/client/addons/org/ui/_site/components/portal/activityCard.js rename to arma/client/addons/org/ui/src/components/portal/activityCard.js index 6060f41..19316aa 100644 --- a/arma/client/addons/org/ui/_site/components/portal/activityCard.js +++ b/arma/client/addons/org/ui/src/components/portal/activityCard.js @@ -51,6 +51,7 @@ ${scopeSelector} .org-activity-time { OrgPortal.componentFns.ActivityCard = function ActivityCard() { const PanelCard = window.SharedUI.componentFns.PanelCard; + const activity = OrgPortal.store.getActivity(); ensureScopedStyle("portal-activity-card", activityCardCss); return PanelCard({ @@ -61,7 +62,7 @@ ${scopeSelector} .org-activity-time { body: h( "div", { className: "org-activity-list" }, - ...portalData.activity.map((item) => + ...activity.map((item) => h( "article", { className: "org-activity-row" }, diff --git a/arma/client/addons/org/ui/_site/components/portal/assetsCard.js b/arma/client/addons/org/ui/src/components/portal/assetsCard.js similarity index 96% rename from arma/client/addons/org/ui/_site/components/portal/assetsCard.js rename to arma/client/addons/org/ui/src/components/portal/assetsCard.js index 4d5e587..f33ea67 100644 --- a/arma/client/addons/org/ui/_site/components/portal/assetsCard.js +++ b/arma/client/addons/org/ui/src/components/portal/assetsCard.js @@ -58,6 +58,7 @@ ${scopeSelector} .org-simple-meta { OrgPortal.componentFns.AssetsCard = function AssetsCard() { const PanelCard = window.SharedUI.componentFns.PanelCard; const SimpleStat = OrgPortal.componentFns.SimpleStat; + const assets = OrgPortal.store.getAssets(); ensureScopedStyle("portal-assets-card", assetsCardCss); return PanelCard({ @@ -68,7 +69,7 @@ ${scopeSelector} .org-simple-meta { body: h( "div", { className: "org-simple-list" }, - ...portalData.assets.map((asset) => + ...assets.map((asset) => h( "article", { className: "org-simple-row" }, diff --git a/arma/client/addons/org/ui/_site/components/portal/dangerCard.js b/arma/client/addons/org/ui/src/components/portal/dangerCard.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/portal/dangerCard.js rename to arma/client/addons/org/ui/src/components/portal/dangerCard.js diff --git a/arma/client/addons/org/ui/_site/components/portal/fleetCard.js b/arma/client/addons/org/ui/src/components/portal/fleetCard.js similarity index 95% rename from arma/client/addons/org/ui/_site/components/portal/fleetCard.js rename to arma/client/addons/org/ui/src/components/portal/fleetCard.js index ad91f88..a6b368b 100644 --- a/arma/client/addons/org/ui/_site/components/portal/fleetCard.js +++ b/arma/client/addons/org/ui/src/components/portal/fleetCard.js @@ -18,11 +18,6 @@ ${scopeSelector} .org-simple-list { scrollbar-color: #94a3b8 #e2e8f0; } -${scopeSelector} { - min-height: 32.5rem; - max-height: 32.5rem; -} - ${scopeSelector} .org-simple-row { display: flex; align-items: center; @@ -63,6 +58,7 @@ ${scopeSelector} .org-simple-meta { OrgPortal.componentFns.FleetCard = function FleetCard() { const PanelCard = window.SharedUI.componentFns.PanelCard; const SimpleStat = OrgPortal.componentFns.SimpleStat; + const fleet = OrgPortal.store.getFleet(); ensureScopedStyle("portal-fleet-card", fleetCardCss); return PanelCard({ @@ -74,7 +70,7 @@ ${scopeSelector} .org-simple-meta { body: h( "div", { className: "org-simple-list" }, - ...portalData.fleet.map((unit) => + ...fleet.map((unit) => h( "article", { className: "org-simple-row" }, diff --git a/arma/client/addons/org/ui/_site/components/portal/futureCard.js b/arma/client/addons/org/ui/src/components/portal/futureCard.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/portal/futureCard.js rename to arma/client/addons/org/ui/src/components/portal/futureCard.js diff --git a/arma/client/addons/org/ui/_site/components/portal/membersCard.js b/arma/client/addons/org/ui/src/components/portal/membersCard.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/portal/membersCard.js rename to arma/client/addons/org/ui/src/components/portal/membersCard.js diff --git a/arma/client/addons/org/ui/_site/components/portal/metricCard.js b/arma/client/addons/org/ui/src/components/portal/metricCard.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/portal/metricCard.js rename to arma/client/addons/org/ui/src/components/portal/metricCard.js diff --git a/arma/client/addons/org/ui/_site/components/portal/modalLayer.js b/arma/client/addons/org/ui/src/components/portal/modalLayer.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/portal/modalLayer.js rename to arma/client/addons/org/ui/src/components/portal/modalLayer.js diff --git a/arma/client/addons/org/ui/_site/components/portal/overviewCard.js b/arma/client/addons/org/ui/src/components/portal/overviewCard.js similarity index 92% rename from arma/client/addons/org/ui/_site/components/portal/overviewCard.js rename to arma/client/addons/org/ui/src/components/portal/overviewCard.js index 42e5ee5..050a97f 100644 --- a/arma/client/addons/org/ui/_site/components/portal/overviewCard.js +++ b/arma/client/addons/org/ui/src/components/portal/overviewCard.js @@ -77,6 +77,11 @@ ${scopeSelector} .org-metric-grid { const PanelCard = window.SharedUI.componentFns.PanelCard; const readiness = getters.getAssetReadiness(); const headquarters = portalData.org.headquarters || "ArmA Verse"; + const assetCount = store.getAssets().length; + const fleetCount = store.getFleet().length; + const funds = store.getFunds(); + const memberCount = store.getMembers().length; + const reputation = store.getReputation(); ensureScopedStyle("portal-overview-card", overviewCardCss); return PanelCard({ @@ -126,7 +131,7 @@ ${scopeSelector} .org-metric-grid { h( "span", { className: "org-meta-value" }, - `${store.getMembers().length} total`, + `${memberCount} total`, ), ), h( @@ -150,22 +155,22 @@ ${scopeSelector} .org-metric-grid { { className: "org-metric-grid" }, MetricCard( "Org Funds", - getters.formatCurrency(store.getFunds()), + getters.formatCurrency(funds), "Organization treasury balance", ), MetricCard( "Reputation", - portalData.reputation, + reputation, "Organization standing", ), MetricCard( "Asset Lines", - portalData.assets.length, + assetCount, "Tracked supply and equipment entries", ), MetricCard( "Fleet Vehicles", - portalData.fleet.length, + fleetCount, "Tracked air, ground, and naval vehicles", ), ), diff --git a/arma/client/addons/org/ui/_site/components/portal/simpleStat.js b/arma/client/addons/org/ui/src/components/portal/simpleStat.js similarity index 100% rename from arma/client/addons/org/ui/_site/components/portal/simpleStat.js rename to arma/client/addons/org/ui/src/components/portal/simpleStat.js diff --git a/arma/client/addons/org/ui/_site/components/portal/treasuryCard.js b/arma/client/addons/org/ui/src/components/portal/treasuryCard.js similarity index 96% rename from arma/client/addons/org/ui/_site/components/portal/treasuryCard.js rename to arma/client/addons/org/ui/src/components/portal/treasuryCard.js index caa57d7..0de5144 100644 --- a/arma/client/addons/org/ui/_site/components/portal/treasuryCard.js +++ b/arma/client/addons/org/ui/src/components/portal/treasuryCard.js @@ -145,6 +145,18 @@ ${scopeSelector} .org-credit-lines-list { gap: 0.85rem; } +${scopeSelector} .org-treasury-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + ${scopeSelector} .org-credit-line-row { display: flex; align-items: center; @@ -199,6 +211,7 @@ ${scopeSelector} .org-credit-line-empty { OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { const PanelCard = window.SharedUI.componentFns.PanelCard; const creditLines = store.getCreditLines(); + const reputation = store.getReputation(); const allowTreasuryActions = getters.canManageTreasury(); const activeTab = getTreasuryTab(); const isMenuOpen = getTreasuryMenuOpen(); @@ -209,9 +222,9 @@ ${scopeSelector} .org-credit-line-empty { ensureScopedStyle("portal-treasury-card", treasuryCardCss); return PanelCard({ - className: "org-span-5", + className: "org-scroll-panel org-span-5", title: "Treasury", - subtitle: "Organization funds, reputation, and member payouts.", + subtitle: "Organization funds, reputation and payouts.", headerExtras: h( "div", { className: "org-treasury-menu" }, @@ -280,7 +293,7 @@ ${scopeSelector} .org-credit-line-empty { rootProps: { [scopeAttr]: "" }, body: h( "div", - null, + { className: "org-treasury-body" }, activeTab === "credit" ? creditLines.length > 0 ? h( @@ -364,7 +377,7 @@ ${scopeSelector} .org-credit-line-empty { { className: "org-meta-label" }, "Reputation", ), - h("strong", null, `${portalData.reputation}`), + h("strong", null, `${reputation}`), ), ), allowTreasuryActions diff --git a/arma/client/addons/org/ui/src/portal/actions.js b/arma/client/addons/org/ui/src/portal/actions.js new file mode 100644 index 0000000..d27e99c --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/actions.js @@ -0,0 +1,300 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const registryStore = window.RegistryApp.store; + + class OrgPortalActions { + constructor() { + this.treasuryNoticeTimer = null; + } + + showTreasuryNotice(type, text) { + store.setTreasuryNotice({ type, text }); + + if (this.treasuryNoticeTimer) { + clearTimeout(this.treasuryNoticeTimer); + } + + this.treasuryNoticeTimer = setTimeout(() => { + store.setTreasuryNotice({ type: "", text: "" }); + this.treasuryNoticeTimer = null; + }, 3500); + } + + parseAmount(value) { + const amount = Number(value); + return Number.isFinite(amount) ? Math.round(amount) : 0; + } + + getInputValue(id) { + const el = document.getElementById(id); + return el ? el.value : ""; + } + + closePortal() { + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (bridge && typeof bridge.close === "function") { + bridge.close({}); + return; + } + + if (registryStore) { + registryStore.setView("home"); + } + } + + openModal(type) { + if ( + (type === "payroll" || + type === "transfer" || + type === "credit") && + !getters.canManageTreasury() + ) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return; + } + + if (type === "disband" && !getters.canDisbandOrg()) { + return; + } + + if (type === "leave" && !getters.canLeaveOrg()) { + return; + } + + store.setModal({ type }); + } + + closeModal() { + store.setModal(null); + } + + removeMember(member) { + if (!getters.canManageMembers()) { + return false; + } + + if (getters.isProtectedMember(member)) { + return false; + } + + const memberUid = getters.getMemberUid(member); + const memberName = getters.getMemberName(member); + + store.setMembers((currentMembers) => + currentMembers.filter((entry) => + memberUid + ? entry.uid !== memberUid + : entry.name !== memberName, + ), + ); + store.setCreditLines((currentLines) => + currentLines.filter((line) => + memberUid + ? line.uid !== memberUid + : line.member !== memberName, + ), + ); + return true; + } + + disbandOrganization() { + if (!getters.canDisbandOrg()) { + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestDisbandOrg !== "function") { + this.showTreasuryNotice( + "error", + "Disband bridge is unavailable.", + ); + return false; + } + + this.closeModal(); + bridge.requestDisbandOrg(); + return true; + } + + leaveOrganization() { + if (!getters.canLeaveOrg()) { + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestLeaveOrg !== "function") { + this.showTreasuryNotice( + "error", + "Leave bridge is unavailable.", + ); + return false; + } + + this.closeModal(); + bridge.requestLeaveOrg(); + return true; + } + + runPayroll(amountPerMember) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const members = store.getMembers(); + const funds = store.getFunds(); + + if (members.length === 0) { + this.showTreasuryNotice( + "error", + "No members available for payroll.", + ); + return false; + } + + if (amountPerMember <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid payroll amount.", + ); + return false; + } + + const total = amountPerMember * members.length; + if (total > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for payroll.", + ); + return false; + } + + store.setFunds(funds - total); + this.showTreasuryNotice( + "success", + `Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`, + ); + return true; + } + + sendFundsToMember(memberName, amount) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const funds = store.getFunds(); + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Select a member to receive funds.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid transfer amount.", + ); + return false; + } + + if (amount > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for this transfer.", + ); + return false; + } + + store.setFunds(funds - amount); + this.showTreasuryNotice( + "success", + `${getters.formatCurrency(amount)} sent to ${memberName}.`, + ); + return true; + } + + grantCreditLine(memberUid, amount) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + if (!memberUid) { + this.showTreasuryNotice( + "error", + "Select a member for the credit line.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid credit line amount.", + ); + return false; + } + + const member = store + .getMembers() + .find((entry) => getters.getMemberUid(entry) === memberUid); + const memberName = member ? getters.getMemberName(member) : ""; + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Selected member was not found in the organization roster.", + ); + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestCreditLine !== "function") { + this.showTreasuryNotice( + "error", + "Credit line bridge is unavailable.", + ); + return false; + } + + return bridge.requestCreditLine({ + memberUid, + memberName, + amount, + }); + } + } + + OrgPortal.actions = new OrgPortalActions(); +})(); diff --git a/arma/client/addons/org/ui/_site/portal/data.js b/arma/client/addons/org/ui/src/portal/data.js similarity index 100% rename from arma/client/addons/org/ui/_site/portal/data.js rename to arma/client/addons/org/ui/src/portal/data.js diff --git a/arma/client/addons/org/ui/src/portal/getters.js b/arma/client/addons/org/ui/src/portal/getters.js new file mode 100644 index 0000000..534c93c --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/getters.js @@ -0,0 +1,178 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData, session } = OrgPortal.data; + + class OrgPortalGetters { + formatCurrency(value) { + return "$" + Number(value || 0).toLocaleString(); + } + + formatVehicleType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatAssetType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatDisplayName(value) { + if (!value) { + return ""; + } + + return String(value) + .trim() + .split(/\s+/) + .map((part) => { + if (!part) { + return ""; + } + + return ( + part.charAt(0).toUpperCase() + + part.slice(1).toLowerCase() + ); + }) + .join(" "); + } + + getAssetReadiness() { + const fleet = OrgPortal.store + ? OrgPortal.store.getFleet() + : portalData.fleet; + if (fleet.length === 0) { + return null; + } + + const total = fleet.reduce( + (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), + 0, + ); + return Math.round(total / fleet.length); + } + + getNormalizedRole() { + return String(session.role || "") + .trim() + .toUpperCase(); + } + + isDefaultOrg() { + return ( + portalData.org.isDefault === true || + String(portalData.org.tag || "") + .trim() + .toUpperCase() === "DEFAULT" + ); + } + + isOrgOwner() { + const ownerUid = String( + portalData.org.ownerUid || portalData.org.owner || "", + ) + .trim() + .toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (ownerUid && actorUid) { + return actorUid === ownerUid; + } + + return ( + String(session.actorName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isSessionCeo() { + return session.ceo === true; + } + + isOrgLeaderOrCeo() { + return ( + this.isOrgOwner() || + this.getNormalizedRole() === "LEADER" || + (this.isDefaultOrg() && this.isSessionCeo()) + ); + } + + canManageMembers() { + return this.isOrgLeaderOrCeo(); + } + + canManageTreasury() { + return this.isOrgLeaderOrCeo(); + } + + canDisbandOrg() { + return this.isOrgOwner() && !this.isDefaultOrg(); + } + + canLeaveOrg() { + return !this.isDefaultOrg() && !this.isOrgOwner(); + } + + getMemberName(member) { + if (member && typeof member === "object") { + return String(member.name || ""); + } + + return String(member || ""); + } + + getMemberUid(member) { + if (member && typeof member === "object") { + return String(member.uid || ""); + } + + return ""; + } + + isOwnerMember(member) { + return ( + this.getMemberName(member).trim().toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isCurrentMember(member) { + const memberUid = this.getMemberUid(member).trim().toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (memberUid && actorUid) { + return memberUid === actorUid; + } + + return ( + this.getMemberName(member).trim().toLowerCase() === + String(session.actorName || "") + .trim() + .toLowerCase() + ); + } + + isProtectedMember(member) { + return this.isOwnerMember(member) || this.isCurrentMember(member); + } + } + + OrgPortal.getters = new OrgPortalGetters(); +})(); diff --git a/arma/client/addons/org/ui/src/portal/store.js b/arma/client/addons/org/ui/src/portal/store.js new file mode 100644 index 0000000..d17a366 --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/store.js @@ -0,0 +1,49 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { createSignal } = window.RegistryApp.runtime; + const { portalData } = OrgPortal.data; + + class OrgPortalStore { + constructor() { + [this.getFunds, this.setFunds] = createSignal(portalData.funds); + [this.getReputation, this.setReputation] = createSignal( + portalData.reputation, + ); + [this.getMembers, this.setMembers] = createSignal([ + ...portalData.members, + ]); + [this.getCreditLines, this.setCreditLines] = createSignal([ + ...portalData.creditLines, + ]); + [this.getFleet, this.setFleet] = createSignal([ + ...portalData.fleet, + ]); + [this.getAssets, this.setAssets] = createSignal([ + ...portalData.assets, + ]); + [this.getActivity, this.setActivity] = createSignal([ + ...portalData.activity, + ]); + [this.getTreasuryNotice, this.setTreasuryNotice] = createSignal({ + type: "", + text: "", + }); + [this.getModal, this.setModal] = createSignal(null); + [this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); + } + + hydrateFromPayload(payload) { + const nextPortalData = payload.portalData || {}; + + this.setFunds(nextPortalData.funds || 0); + this.setReputation(nextPortalData.reputation || 0); + this.setMembers([...(nextPortalData.members || [])]); + this.setCreditLines([...(nextPortalData.creditLines || [])]); + this.setFleet([...(nextPortalData.fleet || [])]); + this.setAssets([...(nextPortalData.assets || [])]); + this.setActivity([...(nextPortalData.activity || [])]); + } + } + + OrgPortal.store = new OrgPortalStore(); +})(); diff --git a/arma/client/addons/org/ui/src/registry/store.js b/arma/client/addons/org/ui/src/registry/store.js new file mode 100644 index 0000000..131ceba --- /dev/null +++ b/arma/client/addons/org/ui/src/registry/store.js @@ -0,0 +1,91 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { createSignal } = RegistryApp.runtime; + + class RegistryStore { + constructor() { + [this.getView, this.setView] = createSignal("home"); + [this.getIsAuthenticating, this.setIsAuthenticating] = + createSignal(false); + [this.getLoginError, this.setLoginError] = createSignal(""); + [this.getIsCreating, this.setIsCreating] = createSignal(false); + [this.getCreateError, this.setCreateError] = createSignal(""); + } + + startLogin() { + this.setLoginError(""); + this.setIsAuthenticating(true); + } + + startCreate() { + this.setCreateError(""); + this.setIsCreating(true); + } + + failLogin(message) { + this.setIsAuthenticating(false); + this.setLoginError(message || "Authentication failed."); + } + + failCreate(message) { + this.setIsCreating(false); + this.setCreateError(message || "Organization registration failed."); + } + + hydratePortal(payload) { + const portalApi = + window.OrgPortal && window.OrgPortal.data + ? window.OrgPortal.data + : null; + const portalStore = + window.OrgPortal && window.OrgPortal.store + ? window.OrgPortal.store + : null; + const portalData = + payload && payload.portalData ? payload.portalData : null; + const sessionData = + payload && payload.session ? payload.session : null; + + if ( + !portalApi || + typeof portalApi.applyLoginPayload !== "function" || + !portalStore || + typeof portalStore.hydrateFromPayload !== "function" || + !portalData || + !sessionData + ) { + return false; + } + + portalApi.applyLoginPayload(payload); + portalStore.hydrateFromPayload(payload); + return true; + } + + completeLogin(payload) { + if (!this.hydratePortal(payload)) { + this.failLogin("Login response was missing portal data."); + return; + } + + this.setLoginError(""); + this.setIsAuthenticating(false); + this.setView("portal"); + } + + completeCreate(payload) { + if (!this.hydratePortal(payload)) { + this.failCreate( + "Organization registration response was missing portal data.", + ); + return; + } + + this.setCreateError(""); + this.setIsCreating(false); + this.setView("portal"); + } + } + + RegistryApp.store = new RegistryStore(); +})(); diff --git a/arma/client/addons/org/ui/src/runtime.js b/arma/client/addons/org/ui/src/runtime.js new file mode 100644 index 0000000..da7fc91 --- /dev/null +++ b/arma/client/addons/org/ui/src/runtime.js @@ -0,0 +1,9 @@ +(function () { + const runtime = window.ForgeWebUI; + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + + RegistryApp.runtime = runtime; + OrgPortal.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/org/ui/src/styles.css b/arma/client/addons/org/ui/src/styles.css new file mode 100644 index 0000000..e4677c8 --- /dev/null +++ b/arma/client/addons/org/ui/src/styles.css @@ -0,0 +1,280 @@ +:root { + --bg-app: #fdfcf8; + --bg-surface: #ffffff; + --bg-surface-hover: #f1f5f9; + --primary: #475569; + --primary-hover: #1e293b; + --text-main: #1f2937; + --text-muted: #64748b; + --text-inverse: #f8fafc; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --footer-bg: #1e293b; +} + +html, +body { + height: 100%; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; + margin: 0; + padding: 0; + background: var(--bg-app); + color: var(--text-main); + line-height: 1.6; + overflow: hidden; +} + +#app { + height: 100vh; + overflow: hidden; +} + +.app-shell { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#org-portal-frame-root { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +main { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} + +.container { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem; + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.header { + text-align: center; + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border); + + h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + letter-spacing: -0.025em; + color: var(--primary-hover); + } + + p { + color: var(--text-muted); + font-size: 1.1rem; + } +} + +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: var(--shadow); + text-align: center; + + h2 { + margin-top: 0; + font-size: 1.8rem; + color: var(--primary-hover); + } +} + +button { + background: var(--primary); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.65; + transform: none; + box-shadow: none; + } + + & + & { + margin-left: 1rem; + } +} + +.footer { + margin-top: auto; + background: var(--footer-bg); + color: var(--text-inverse); + display: block; + + .wrapper { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 3rem 2rem; + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + } + + h3 { + color: var(--text-inverse); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + margin-bottom: 1.5rem; + border-bottom: 1px solid #475569; + padding-bottom: 0.5rem; + margin-right: 1rem; + } + + ul { + li { + color: #cbd5e1; + font-size: 0.95rem; + margin-bottom: 0.75rem; + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: white; + } + } + } +} + +.org-secondary-btn { + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + + &:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + } +} + +.org-danger-btn { + background: #7f1d1d; + color: #fef2f2; + + &:hover { + background: #991b1b; + } +} + +.org-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; +} + +.org-icon { + width: 1rem; + height: 1rem; +} + +.org-page-header { + text-align: left; + margin-bottom: 0; +} + +.org-page-heading { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.org-page-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +.org-page-title { + margin: 0; +} + +.org-page-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.org-page-meta { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +@media (max-width: 960px) { + .container { + padding: 1.5rem; + } + + .header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + + h1 { + font-size: 2rem; + } + } + + .footer .wrapper { + grid-template-columns: 1fr; + } + + .org-page-heading { + gap: 0.3rem; + } +} diff --git a/arma/client/addons/org/ui/_site/views/DisbandedView.js b/arma/client/addons/org/ui/src/views/DisbandedView.js similarity index 100% rename from arma/client/addons/org/ui/_site/views/DisbandedView.js rename to arma/client/addons/org/ui/src/views/DisbandedView.js diff --git a/arma/client/addons/org/ui/_site/views/HomeView.js b/arma/client/addons/org/ui/src/views/HomeView.js similarity index 100% rename from arma/client/addons/org/ui/_site/views/HomeView.js rename to arma/client/addons/org/ui/src/views/HomeView.js diff --git a/arma/client/addons/org/ui/_site/views/PortalView.js b/arma/client/addons/org/ui/src/views/PortalView.js similarity index 67% rename from arma/client/addons/org/ui/_site/views/PortalView.js rename to arma/client/addons/org/ui/src/views/PortalView.js index 8bf61b8..989fa71 100644 --- a/arma/client/addons/org/ui/_site/views/PortalView.js +++ b/arma/client/addons/org/ui/src/views/PortalView.js @@ -8,6 +8,10 @@ ensureScopedStyle( "portal-view", ` + ${portalViewScope} { + --org-row-card-max-height: 36rem; + } + ${portalViewScope} .org-toast-stack { position: fixed; top: 1.5rem; @@ -46,6 +50,7 @@ display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1.5rem; + align-items: stretch; } ${portalViewScope} .org-panel { @@ -56,10 +61,23 @@ ${portalViewScope} .org-scroll-panel { display: flex; flex-direction: column; - max-height: 31rem; + min-height: 0; + max-height: var(--org-row-card-max-height); overflow: hidden; } + ${portalViewScope} .org-island-root { + display: flex; + align-self: stretch; + min-height: 0; + min-width: 0; + } + + ${portalViewScope} .org-island-root > .org-panel { + height: 100%; + width: 100%; + } + ${portalViewScope} .org-span-12 { grid-column: span 12; } @@ -93,26 +111,47 @@ ${portalViewScope} .org-span-5 { grid-column: span 12; } + + ${portalViewScope} .org-scroll-panel { + max-height: none; + } + } `, ); OrgPortal.components = OrgPortal.components || {}; + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.TreasuryNoticeLayer = + function TreasuryNoticeLayer() { + const treasuryNotice = store.getTreasuryNotice(); + if (!treasuryNotice.text) { + return null; + } + + return h( + "div", + { className: "org-toast-stack" }, + h( + "div", + { + className: + treasuryNotice.type === "error" + ? "org-toast is-error" + : "org-toast is-success", + }, + treasuryNotice.text, + ), + ); + }; OrgPortal.components.App = function App() { const Hero = window.SharedUI.componentFns.Hero; const Footer = window.SharedUI.componentFns.Footer; - const OverviewCard = OrgPortal.componentFns.OverviewCard; - const FleetCard = OrgPortal.componentFns.FleetCard; - const TreasuryCard = OrgPortal.componentFns.TreasuryCard; - const MembersCard = OrgPortal.componentFns.MembersCard; - const AssetsCard = OrgPortal.componentFns.AssetsCard; - const ActivityCard = OrgPortal.componentFns.ActivityCard; const FutureCard = OrgPortal.componentFns.FutureCard; const DangerCard = OrgPortal.componentFns.DangerCard; - const ModalLayer = OrgPortal.componentFns.ModalLayer; const DisbandedView = OrgPortal.componentFns.DisbandedView; - const treasuryNotice = store.getTreasuryNotice(); const footerSections = [ { title: "Organization Controls", @@ -133,7 +172,6 @@ ], }, ]; - if (store.getOrgDisbanded()) { return h( "main", @@ -153,7 +191,7 @@ DisbandedView(), ), ), - ModalLayer(), + h("div", { id: "org-portal-modal-root" }), Footer({ sections: footerSections }), ); } @@ -161,22 +199,7 @@ return h( "main", { "data-ui-portal-view": "" }, - treasuryNotice.text - ? h( - "div", - { className: "org-toast-stack" }, - h( - "div", - { - className: - treasuryNotice.type === "error" - ? "org-toast is-error" - : "org-toast is-success", - }, - treasuryNotice.text, - ), - ) - : null, + h("div", { id: "org-portal-toast-root" }), h( "div", { className: "container" }, @@ -189,17 +212,35 @@ subtitle: "Player organization command portal", meta: `${session.actorName} - ${session.role}`, }), - OverviewCard(), - FleetCard(), - TreasuryCard(), - MembersCard(), - AssetsCard(), - ActivityCard(), + h("div", { + className: "org-island-root org-span-12", + id: "org-overview-card-root", + }), + h("div", { + className: "org-island-root org-span-7", + id: "org-fleet-card-root", + }), + h("div", { + className: "org-island-root org-span-5", + id: "org-treasury-card-root", + }), + h("div", { + className: "org-island-root org-span-5", + id: "org-members-card-root", + }), + h("div", { + className: "org-island-root org-span-7", + id: "org-assets-card-root", + }), + h("div", { + className: "org-island-root org-span-6", + id: "org-activity-card-root", + }), FutureCard(), DangerCard(), ), ), - ModalLayer(), + h("div", { id: "org-portal-modal-root" }), Footer({ sections: footerSections }), ); }; diff --git a/arma/client/addons/org/ui/_site/views/RegistrationView.js b/arma/client/addons/org/ui/src/views/RegistrationView.js similarity index 100% rename from arma/client/addons/org/ui/_site/views/RegistrationView.js rename to arma/client/addons/org/ui/src/views/RegistrationView.js diff --git a/arma/client/addons/org/ui/ui.config.mjs b/arma/client/addons/org/ui/ui.config.mjs new file mode 100644 index 0000000..caa5279 --- /dev/null +++ b/arma/client/addons/org/ui/ui.config.mjs @@ -0,0 +1,56 @@ +export default { + addonName: "org", + title: "ORBIS - Global Organization Network", + logLabel: "Org UI", + outputDir: "_site", + jsBundles: [ + { + name: "Org UI app", + output: "org-ui.js", + sources: [ + "src/runtime.js", + "src/registry/store.js", + "src/bridge.js", + "src/portal/data.js", + "src/portal/store.js", + "src/portal/getters.js", + "src/portal/actions.js", + "src/components/navbar.js", + "src/components/header.js", + "src/components/hero.js", + "src/components/footer.js", + "src/components/modal.js", + "src/components/panelCard.js", + "src/components/portal/metricCard.js", + "src/components/portal/simpleStat.js", + "src/components/portal/overviewCard.js", + "src/components/portal/fleetCard.js", + "src/components/portal/treasuryCard.js", + "src/components/portal/assetsCard.js", + "src/components/portal/membersCard.js", + "src/components/portal/activityCard.js", + "src/components/portal/futureCard.js", + "src/components/portal/dangerCard.js", + "src/components/portal/modalLayer.js", + "src/views/DisbandedView.js", + "src/views/PortalView.js", + "src/views/RegistrationView.js", + "src/views/HomeView.js", + "src/components/AppShell.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Org UI styles", + output: "org-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["org-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["org-ui.js"], + }, +}; diff --git a/arma/client/addons/store/XEH_PREP.hpp b/arma/client/addons/store/XEH_PREP.hpp index 0f04984..b8aa106 100644 --- a/arma/client/addons/store/XEH_PREP.hpp +++ b/arma/client/addons/store/XEH_PREP.hpp @@ -1,4 +1,6 @@ +PREP(buildStoreUIPayload); PREP(handleUIEvents); +PREP(initStoreCatalogService); PREP(initStoreClass); PREP(initStoreUIBridge); PREP(openUI); diff --git a/arma/client/addons/store/XEH_postInitClient.sqf b/arma/client/addons/store/XEH_postInitClient.sqf index b2cd6ca..a14751c 100644 --- a/arma/client/addons/store/XEH_postInitClient.sqf +++ b/arma/client/addons/store/XEH_postInitClient.sqf @@ -1,5 +1,6 @@ #include "script_component.hpp" +if (isNil QGVAR(StoreCatalogService)) then { call FUNC(initStoreCatalogService); }; if (isNil QGVAR(StoreClass)) then { call FUNC(initStoreClass); }; if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initStoreUIBridge); }; diff --git a/arma/client/addons/store/config.cpp b/arma/client/addons/store/config.cpp index 8a8ad6f..128e9a9 100644 --- a/arma/client/addons/store/config.cpp +++ b/arma/client/addons/store/config.cpp @@ -8,6 +8,7 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { + "forge_client_common", "forge_client_main" }; units[] = {}; diff --git a/arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf b/arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf new file mode 100644 index 0000000..1f47613 --- /dev/null +++ b/arma/client/addons/store/functions/fnc_buildStoreUIPayload.sqf @@ -0,0 +1,125 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_buildStoreUIPayload.sqf + * Author: IDSolutions + * Date: 2026-03-13 + * Public: No + * + * Description: + * Builds the browser hydrate payload for the store UI from current client state. + * + * Arguments: + * None + * + * Return Value: + * Store UI payload [HASHMAP] + */ + +private _storeState = createHashMap; +private _budget = 50000; +private _creditLine = 0; +private _cashBalance = 0; +private _bankBalance = 0; +private _orgFunds = 0; +private _orgId = ""; +private _orgName = ""; +private _orgOwnerUid = ""; +private _orgCreditLines = createHashMap; +private _playerUid = getPlayerUID player; +private _playerVar = toLowerANSI (vehicleVarName player); +private _isOrgLeader = false; +private _isDefaultOrg = false; +private _isDefaultOrgCeo = false; + +if !(isNil QGVAR(StoreClass)) then { + _storeState = GVAR(StoreClass) call ["getStoreState", []]; + _budget = _storeState getOrDefault ["budget", _budget]; +}; + +if !(isNil QEGVAR(bank,BankClass)) then { + _cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]]; + _bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]]; +}; + +if !(isNil QEGVAR(org,OrgClass)) then { + _orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]]; + _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; + _orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]]; + _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; + _orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]]; + _isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" }; + _isOrgLeader = _orgOwnerUid isEqualTo _playerUid; + _isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; +}; + +if (_orgCreditLines isEqualType createHashMap) then { + private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap]; + if (_playerCreditLine isEqualType createHashMap) then { + _creditLine = _playerCreditLine getOrDefault ["amount", 0]; + }; +}; + +private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo; +private _orgFundsEnabled = _canUseOrgFunds && { _orgFunds > 0 }; +private _paymentSources = [ + createHashMapFromArray [ + ["id", "cash"], + ["label", "Cash"], + ["balance", _cashBalance], + ["enabled", _cashBalance > 0], + ["detail", "Use on-hand cash carried by the player."] + ], + createHashMapFromArray [ + ["id", "bank"], + ["label", "Bank"], + ["balance", _bankBalance], + ["enabled", _bankBalance > 0], + ["detail", "Charge the player bank account."] + ], + createHashMapFromArray [ + ["id", "org_funds"], + ["label", "Org Funds"], + ["balance", _orgFunds], + ["enabled", _orgFundsEnabled], + ["detail", [ + "Only organization leaders or the default-org CEO can use treasury funds.", + [ + "Charge organization treasury funds.", + "No organization funds are currently available." + ] select _orgFundsEnabled + ] select _canUseOrgFunds] + ], + createHashMapFromArray [ + ["id", "credit_line"], + ["label", "Credit Line"], + ["balance", _creditLine], + ["enabled", _creditLine > 0], + ["detail", [ + "No approved credit line is assigned to this member.", + "Use the approved procurement credit line." + ] select (_creditLine > 0)] + ] +]; + +createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", name player], + ["actorUid", _playerUid], + ["approval", "Field Access"], + ["orgId", _orgId], + ["orgName", _orgName], + ["orgLeader", _isOrgLeader], + ["defaultOrgCeo", _isDefaultOrgCeo], + ["canUseOrgFunds", _canUseOrgFunds] + ]], + ["storeConfig", createHashMapFromArray [ + ["budget", _budget], + ["creditLine", _creditLine], + ["availability", _storeState getOrDefault ["availability", "In-Stock"]], + ["moduleState", _storeState getOrDefault ["moduleState", "Preview"]], + ["paymentSources", _paymentSources], + ["defaultPaymentSource", "cash"] + ]], + ["cartItems", []] +] diff --git a/arma/client/addons/store/functions/fnc_initStoreCatalogService.sqf b/arma/client/addons/store/functions/fnc_initStoreCatalogService.sqf new file mode 100644 index 0000000..5674009 --- /dev/null +++ b/arma/client/addons/store/functions/fnc_initStoreCatalogService.sqf @@ -0,0 +1,294 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initStoreCatalogService.sqf + * Author: IDSolutions + * Date: 2026-03-13 + * Public: No + * + * Description: + * Initializes the store catalog service for category discovery, pricing, and payload shaping. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "StoreCatalogServiceBaseClass"], + ["#create", compileFinal { + _self set ["catalogCache", createHashMap]; + }], + ["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 + }], + ["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]] + }], + ["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", _self 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 { [] }; + + 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 } + }], + ["isVehicleCategory", compileFinal { + params [["_category", "", [""]]]; + + (toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"] + }], + ["buildPayloadCategory", compileFinal { + params [["_category", "", [""]]]; + + switch (toLowerANSI _category) do { + case "ammo": { "magazine" }; + case "primary"; + case "secondary"; + case "handgun": { "weapon" }; + case "cars"; + case "armor"; + case "helis"; + case "planes"; + case "naval"; + case "other": { toLowerANSI _category }; + default { "item" }; + } + }], + ["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]]; + private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]]; + private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]); + + { + _x set ["category", _payloadCategory]; + _x set ["entryKind", _entryKind]; + } forEach _items; + + _catalogCache set [_categoryKey, _items]; + _self set ["catalogCache", _catalogCache]; + + _items + }] +]; + +GVAR(StoreCatalogService) = createHashMapObject [GVAR(StoreCatalogServiceBaseClass)]; +GVAR(StoreCatalogService) diff --git a/arma/client/addons/store/functions/fnc_initStoreClass.sqf b/arma/client/addons/store/functions/fnc_initStoreClass.sqf index 0c1f781..583e131 100644 --- a/arma/client/addons/store/functions/fnc_initStoreClass.sqf +++ b/arma/client/addons/store/functions/fnc_initStoreClass.sqf @@ -25,156 +25,16 @@ GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [ ["#type", "StoreBaseClass"], ["#create", compileFinal { _self set ["uid", getPlayerUID player]; - _self set ["store", createHashMap]; - _self set ["workspace", createHashMapFromArray [ - ["budget", 48000], - ["creditLine", 0], - ["availability", "Open"], - ["approval", "Field Access"], - ["moduleState", "Preview"], - ["searchTags", ["Field", "Logistics", "Issued", "Restricted"]] + _self set ["store", createHashMapFromArray [ + ["budget", 50000], + ["availability", "In-Stock"], + ["moduleState", "Preview"] ]]; _self set ["isLoaded", false]; _self set ["lastSave", time]; - - systemChat format ["Store class initialized for %1", name player]; - diag_log "[FORGE:Client:Store] Store Class Initialized!"; }], - ["buildUIPayload", compileFinal { - private _workspace = _self getOrDefault ["workspace", createHashMap]; - private _budget = _workspace getOrDefault ["budget", 48000]; - private _creditLine = _workspace getOrDefault ["creditLine", 0]; - private _cashBalance = 0; - private _bankBalance = 0; - private _orgFunds = 0; - private _orgId = ""; - private _orgName = ""; - private _orgOwnerUid = ""; - private _orgCreditLines = createHashMap; - private _playerUid = getPlayerUID player; - private _playerVar = toLowerANSI (vehicleVarName player); - private _isOrgLeader = false; - private _isDefaultOrg = false; - private _isDefaultOrgCeo = false; - - if !(isNil QEGVAR(bank,BankClass)) then { - _cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]]; - _bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]]; - }; - - if !(isNil QEGVAR(org,OrgClass)) then { - _orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]]; - _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; - _orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]]; - _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; - _orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]]; - _isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" }; - _isOrgLeader = _orgOwnerUid isEqualTo _playerUid; - _isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; - }; - - if (_orgCreditLines isEqualType createHashMap) then { - private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap]; - if (_playerCreditLine isEqualType createHashMap) then { - _creditLine = _playerCreditLine getOrDefault ["amount", _creditLine]; - }; - }; - - private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo; - private _orgFundsEnabled = _canUseOrgFunds && {_orgFunds > 0}; - private _paymentSources = [ - createHashMapFromArray [ - ["id", "cash"], - ["label", "Cash"], - ["balance", _cashBalance], - ["enabled", _cashBalance > 0], - ["detail", "Use on-hand cash carried by the player."] - ], - createHashMapFromArray [ - ["id", "bank"], - ["label", "Bank"], - ["balance", _bankBalance], - ["enabled", _bankBalance > 0], - ["detail", "Charge the player bank account."] - ], - createHashMapFromArray [ - ["id", "org_funds"], - ["label", "Org Funds"], - ["balance", _orgFunds], - ["enabled", _orgFundsEnabled], - ["detail", [ - "Only organization leaders or the default-org CEO can use treasury funds.", - [ - "Charge organization treasury funds.", - "No organization funds are currently available." - ] select _orgFundsEnabled - ] select _canUseOrgFunds] - ], - createHashMapFromArray [ - ["id", "credit_line"], - ["label", "Credit Line"], - ["balance", _creditLine], - ["enabled", _creditLine > 0], - ["detail", [ - "No approved credit line is assigned to this member.", - "Use the approved procurement credit line." - ] select (_creditLine > 0)] - ] - ]; - - createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["actorName", name player], - ["actorUid", getPlayerUID player], - ["approvalRole", _workspace getOrDefault ["approval", "Field Access"]], - ["orgId", _orgId], - ["orgName", _orgName], - ["orgLeader", _isOrgLeader], - ["defaultOrgCeo", _isDefaultOrgCeo], - ["canUseOrgFunds", _canUseOrgFunds] - ]], - ["workspace", createHashMapFromArray [ - ["budget", _budget], - ["creditLine", _creditLine], - ["availability", _workspace getOrDefault ["availability", "Open"]], - ["approval", _workspace getOrDefault ["approval", "Field Access"]], - ["moduleState", _workspace getOrDefault ["moduleState", "Preview"]], - ["searchTags", _workspace getOrDefault ["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]], - ["paymentSources", _paymentSources], - ["defaultPaymentSource", "cash"] - ]], - ["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]] + ["getStoreState", compileFinal { + _self getOrDefault ["store", createHashMap] }] ]; diff --git a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf b/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf index e626919..b600593 100644 --- a/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf +++ b/arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf @@ -14,9 +14,6 @@ #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 }; @@ -48,258 +45,10 @@ 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 } - }], - ["isVehicleCategory", compileFinal { - params [["_category", "", [""]]]; - - (toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"] - }], - ["buildPayloadCategory", compileFinal { - params [["_category", "", [""]]]; - - switch (toLowerANSI _category) do { - case "ammo": { "magazine" }; - case "primary"; - case "secondary"; - case "handgun": { "weapon" }; - case "cars"; - case "armor"; - case "helis"; - case "planes"; - case "naval"; - case "other": { toLowerANSI _category }; - default { "item" }; - } - }], - ["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]]; - private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]]; - private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]); - - { - _x set ["category", _payloadCategory]; - _x set ["entryKind", _entryKind]; - } forEach _items; - - _catalogCache set [_categoryKey, _items]; - _self set ["catalogCache", _catalogCache]; - - _items - }], ["handleReady", compileFinal { params [["_control", controlNull, [controlNull]]]; - private _payload = if (isNil QGVAR(StoreClass)) then { - createHashMap - } else { - GVAR(StoreClass) call ["buildUIPayload", []] - }; - + private _payload = call FUNC(buildStoreUIPayload); _self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]]; }], ["handleCategoryRequest", compileFinal { @@ -312,14 +61,14 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ]]]; }; - if (isNil QGVAR(StoreClass)) exitWith { + if (isNil QGVAR(StoreCatalogService)) exitWith { _self call ["sendBridgeEvent", ["store::category::failure", createHashMapFromArray [ ["category", _category], - ["message", "Store data is unavailable."] + ["message", "Store catalog is unavailable."] ]]]; }; - private _items = _self call ["buildCategoryItems", [_category]]; + private _items = GVAR(StoreCatalogService) call ["buildCategoryItems", [_category]]; diag_log format ["[FORGE:Client:Store] Category request handled for %1 with %2 item(s).", _category, count _items]; @@ -328,9 +77,9 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["items", _items] ]]]; }], - ["refreshWorkspace", compileFinal { - private _payload = GVAR(StoreClass) call ["buildUIPayload", []]; - _self call ["sendBridgeEvent", ["store::workspace::hydrate", _payload]]; + ["refreshStoreConfig", compileFinal { + private _payload = call FUNC(buildStoreUIPayload); + _self call ["sendBridgeEvent", ["store::config::hydrate", _payload]]; }], ["handleCheckoutRequest", compileFinal { params [["_data", createHashMap, [createHashMap]]]; @@ -358,7 +107,7 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ [] spawn { sleep 0.05; if !(isNil QGVAR(StoreUIBridge)) then { - GVAR(StoreUIBridge) call ["refreshWorkspace", []]; + GVAR(StoreUIBridge) call ["refreshStoreConfig", []]; }; }; }; diff --git a/arma/client/addons/store/ui/_site/bridge.js b/arma/client/addons/store/ui/_site/bridge.js deleted file mode 100644 index 9b669ab..0000000 --- a/arma/client/addons/store/ui/_site/bridge.js +++ /dev/null @@ -1,117 +0,0 @@ -(function () { - const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); - const store = StorefrontApp.store; - - function sendEvent(event, data) { - if ( - typeof A3API !== "undefined" && - typeof A3API.SendAlert === "function" - ) { - A3API.SendAlert( - JSON.stringify({ - event, - data, - }), - ); - return true; - } - - return false; - } - - function requestClose() { - return sendEvent("store::close", {}); - } - - function requestCheckout(payload) { - return sendEvent("store::checkout::request", payload); - } - - function requestCategory(payload) { - return sendEvent("store::category::request", payload); - } - - function notifyReady() { - return sendEvent("store::ready", { loaded: true }); - } - - function receive(eventOrPayload, data = {}) { - const event = - typeof eventOrPayload === "object" && eventOrPayload !== null - ? eventOrPayload.event - : eventOrPayload; - const payloadData = - typeof eventOrPayload === "object" && eventOrPayload !== null - ? eventOrPayload.data || {} - : data; - - if (event === "store::hydrate") { - StorefrontApp.data.applyHydratePayload(payloadData); - store.hydrateFromPayload(payloadData); - return; - } - - if (event === "store::workspace::hydrate") { - StorefrontApp.data.applyHydratePayload(payloadData); - store.hydrateWorkspace(payloadData); - return; - } - - if (event === "store::checkout::success") { - store.setIsCheckingOut(false); - store.setCartItems([]); - store.setCartOpen(false); - if (StorefrontApp.actions) { - StorefrontApp.actions.showNotice( - "success", - payloadData.message || "Checkout completed.", - ); - } - 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) { - StorefrontApp.actions.showNotice( - "error", - payloadData.message || "Checkout failed.", - ); - } - } - } - - StorefrontApp.bridge = { - sendEvent, - requestClose, - requestCheckout, - requestCategory, - notifyReady, - receive, - }; - - window.StoreUIBridge = { - requestClose, - requestCheckout, - requestCategory, - notifyReady, - receive, - receiveHydrate: (data) => receive("store::hydrate", data), - }; -})(); diff --git a/arma/client/addons/store/ui/_site/data.js b/arma/client/addons/store/ui/_site/data.js deleted file mode 100644 index 6f191e2..0000000 --- a/arma/client/addons/store/ui/_site/data.js +++ /dev/null @@ -1,456 +0,0 @@ -(function () { - const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); - - const defaultSession = { - actorName: "", - actorUid: "", - approvalRole: "Field Access", - orgId: "", - orgName: "", - orgLeader: false, - defaultOrgCeo: false, - canUseOrgFunds: false, - }; - - const defaultStoreConfig = { - budget: 48000, - creditLine: 0, - availability: "Open", - approval: "Field Access", - moduleState: "Preview", - searchTags: ["Field", "Logistics", "Issued", "Restricted"], - paymentSources: [ - { - id: "cash", - label: "Cash", - balance: 0, - enabled: false, - detail: "Use on-hand cash carried by the player.", - }, - { - id: "bank", - label: "Bank", - balance: 0, - enabled: false, - detail: "Charge the player bank account.", - }, - { - id: "org_funds", - label: "Org Funds", - balance: 0, - enabled: false, - detail: "Only organization leaders or the default-org CEO can use treasury funds.", - }, - { - id: "credit_line", - label: "Credit Line", - balance: 0, - enabled: false, - detail: "No approved credit line is assigned to this member.", - }, - ], - defaultPaymentSource: "cash", - }; - - function cloneValue(value) { - return JSON.parse(JSON.stringify(value)); - } - - function replaceObject(target, source) { - Object.keys(target).forEach((key) => delete target[key]); - Object.assign(target, cloneValue(source)); - } - - const catalog = { - categoryCards: [ - { id: "uniforms", label: "Uniforms" }, - { id: "headgear", label: "Headgear" }, - { id: "facewear", label: "Facewear" }, - { id: "vests", label: "Vests" }, - { id: "weapons", label: "Weapons" }, - { id: "ammo", label: "Ammo" }, - { id: "items", label: "Items" }, - { id: "vehicles", label: "Vehicles" }, - ], - vehicleCards: [ - { id: "cars", label: "Cars" }, - { id: "armor", label: "Armor" }, - { id: "helis", label: "Helicopters" }, - { id: "planes", label: "Planes" }, - { id: "naval", label: "Naval" }, - { id: "other", label: "Other" }, - ], - weaponCards: [ - { id: "primary", label: "Primary" }, - { id: "secondary", label: "Secondary" }, - { id: "handgun", label: "Handgun" }, - ], - previewItems: { - uniforms: [ - { - code: "UNF-102", - name: "Field Uniform", - description: - "Standard issue apparel block reserved for mission-ready clothing sets.", - price: "$1,250", - }, - { - code: "UNF-214", - name: "Combat Uniform", - description: - "Hardened kit placeholder for armored and specialized duty loadouts.", - price: "$1,980", - }, - { - code: "UNF-330", - name: "Duty Uniform", - description: - "Administrative and garrison wear preview for storefront layout validation.", - price: "$890", - }, - ], - headgear: [ - { - code: "HDG-044", - name: "Patrol Helmet", - description: - "Protective headgear module with placeholder image frame and pricing slot.", - price: "$640", - }, - { - code: "HDG-107", - name: "Operator Cap", - description: - "Soft headwear entry for non-armored and low-profile equipment sets.", - price: "$120", - }, - { - code: "HDG-221", - name: "Boonie Hat", - description: - "Terrain-adapted headwear card for storefront presentation.", - price: "$95", - }, - ], - facewear: [ - { - code: "FAC-015", - name: "Protective Goggles", - description: - "Facewear module placeholder aligned to the shared supply exchange layout.", - price: "$220", - }, - { - code: "FAC-028", - name: "Balaclava", - description: - "Low-profile face covering preview card for catalog expansion.", - price: "$74", - }, - { - code: "FAC-091", - name: "Respirator Mask", - description: - "Filtered facewear placeholder for hazard and industrial kit sets.", - price: "$410", - }, - ], - vests: [ - { - code: "VST-311", - name: "Carrier Rig", - description: - "Plate carrier preview item with a reserved image zone and pricing footer.", - price: "$2,430", - }, - { - code: "VST-414", - name: "Patrol Vest", - description: - "Mid-weight vest card intended for security and checkpoint loadouts.", - price: "$1,320", - }, - { - code: "VST-558", - name: "Utility Harness", - description: - "Storage-focused chest rig placeholder for non-ballistic kit sets.", - price: "$760", - }, - ], - ammo: [ - { - code: "AMM-556", - name: "5.56 Cartridge Pack", - description: - "Grouped ammunition supply card with placeholder product art.", - price: "$180", - }, - { - code: "AMM-762", - name: "7.62 Cartridge Pack", - description: - "Extended-caliber ammunition block for rifle and marksman loadouts.", - price: "$220", - }, - { - code: "AMM-9MM", - name: "9mm Cartridge Pack", - description: - "Compact sidearm ammunition placeholder entry for layout review.", - price: "$70", - }, - ], - items: [ - { - code: "ITM-004", - name: "First Aid Kit", - description: - "Support item placeholder designed to preview general utility inventory cards.", - price: "$65", - }, - { - code: "ITM-089", - name: "Radio Module", - description: - "Communications item block with the same product card treatment as all categories.", - price: "$330", - }, - { - code: "ITM-217", - name: "Tool Kit", - description: - "Repair and engineering support module placeholder for store browsing.", - price: "$145", - }, - ], - primary: [ - { - code: "WPN-PRI-01", - name: "Primary Platform A", - description: - "Primary weapon slot placeholder card for mock store review.", - price: "$3,250", - }, - { - code: "WPN-PRI-02", - name: "Primary Platform B", - description: - "Alternate long-arm placeholder with image frame and metadata treatment.", - price: "$3,980", - }, - { - code: "WPN-PRI-03", - name: "Primary Platform C", - description: - "General-purpose primary slot preview for future catalog wiring.", - price: "$2,890", - }, - ], - secondary: [ - { - code: "WPN-SEC-01", - name: "Secondary Launcher A", - description: - "Secondary slot placeholder card for support and utility weapon systems.", - price: "$5,600", - }, - { - code: "WPN-SEC-02", - name: "Secondary Launcher B", - description: - "Compact shoulder-fired placeholder entry in the shared product style.", - price: "$4,950", - }, - { - code: "WPN-SEC-03", - name: "Secondary Launcher C", - description: - "Reserved card for extended secondary inventory logic tomorrow.", - price: "$6,120", - }, - ], - handgun: [ - { - code: "WPN-HND-01", - name: "Sidearm A", - description: - "Handgun slot placeholder card with shared visual language.", - price: "$780", - }, - { - code: "WPN-HND-02", - name: "Sidearm B", - description: - "Secondary sidearm preview block for storefront evaluation.", - price: "$920", - }, - { - code: "WPN-HND-03", - name: "Sidearm C", - description: - "Compact sidearm placeholder with the same product framing.", - price: "$860", - }, - ], - cars: [ - { - code: "VEH-CAR-01", - name: "Patrol Utility Car", - description: - "Light wheeled vehicle placeholder for quick response and urban transport.", - price: "$12,500", - }, - { - code: "VEH-CAR-02", - name: "Transport Van", - description: - "Personnel transport preview card using the shared product layout.", - price: "$18,200", - }, - { - code: "VEH-CAR-03", - name: "Recon SUV", - description: - "Recon-focused platform placeholder with pricing and metadata treatment.", - price: "$21,900", - }, - ], - armor: [ - { - code: "VEH-ARM-01", - name: "APC Variant A", - description: - "Armored personnel carrier placeholder reserved for heavy vehicle inventory.", - price: "$145,000", - }, - { - code: "VEH-ARM-02", - name: "IFV Variant B", - description: - "Tracked armored platform preview aligned to category card behavior.", - price: "$228,000", - }, - { - code: "VEH-ARM-03", - name: "Support Armor C", - description: - "Heavy support vehicle placeholder for future role-based filtering.", - price: "$174,500", - }, - ], - helis: [ - { - code: "VEH-HEL-01", - name: "Light Heli A", - description: - "Rotorcraft placeholder for scouting and rapid insertion use cases.", - price: "$325,000", - }, - { - code: "VEH-HEL-02", - name: "Transport Heli B", - description: - "Medium-lift helicopter preview item with staged catalog metadata.", - price: "$482,000", - }, - { - code: "VEH-HEL-03", - name: "Attack Heli C", - description: - "Combat helicopter placeholder for future weapon package wiring.", - price: "$690,000", - }, - ], - planes: [ - { - code: "VEH-PLN-01", - name: "Fixed-Wing Trainer", - description: - "Basic aircraft placeholder for pilot training and logistics transfer.", - price: "$760,000", - }, - { - code: "VEH-PLN-02", - name: "Utility Plane", - description: - "General-purpose plane preview card in the shared storefront style.", - price: "$1,120,000", - }, - { - code: "VEH-PLN-03", - name: "Strike Plane", - description: - "Fixed-wing strike platform placeholder for high-tier procurement.", - price: "$1,860,000", - }, - ], - naval: [ - { - code: "VEH-NAV-01", - name: "Patrol Boat", - description: - "Shallow-water patrol vessel placeholder for littoral operations.", - price: "$92,000", - }, - { - code: "VEH-NAV-02", - name: "Assault Boat", - description: - "Assault transport craft preview entry with staged catalog attributes.", - price: "$128,000", - }, - { - code: "VEH-NAV-03", - name: "Support Craft", - description: - "Utility naval craft placeholder for resupply and extraction workflows.", - price: "$104,000", - }, - ], - other: [ - { - code: "VEH-OTH-01", - name: "UAV Support Unit", - description: - "Unmanned vehicle placeholder grouped under miscellaneous platforms.", - price: "$48,000", - }, - { - code: "VEH-OTH-02", - name: "Static Transport Module", - description: - "Special transport asset preview for non-standard deployment types.", - price: "$67,000", - }, - { - code: "VEH-OTH-03", - name: "Service Platform", - description: - "General support platform placeholder for future specialty categories.", - price: "$58,500", - }, - ], - }, - }; - - StorefrontApp.data = { - catalog, - session: Object.assign({}, defaultSession), - storeConfig: Object.assign({}, defaultStoreConfig), - applyHydratePayload(payload) { - replaceObject( - this.session, - Object.assign({}, defaultSession, payload?.session || {}), - ); - replaceObject( - this.storeConfig, - Object.assign( - {}, - defaultStoreConfig, - payload?.workspace || payload?.storeConfig || {}, - ), - ); - }, - }; -})(); diff --git a/arma/client/addons/store/ui/_site/index.html b/arma/client/addons/store/ui/_site/index.html index 5ce3d9a..019f432 100644 --- a/arma/client/addons/store/ui/_site/index.html +++ b/arma/client/addons/store/ui/_site/index.html @@ -1,3 +1,4 @@ + @@ -5,69 +6,55 @@ FORGE Supply Exchange diff --git a/arma/client/addons/store/ui/_site/runtime.js b/arma/client/addons/store/ui/_site/runtime.js deleted file mode 100644 index f3879fb..0000000 --- a/arma/client/addons/store/ui/_site/runtime.js +++ /dev/null @@ -1,193 +0,0 @@ -(function () { - const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); - - const SVG_NS = "http://www.w3.org/2000/svg"; - const SVG_TAGS = new Set([ - "svg", - "path", - "circle", - "rect", - "line", - "polyline", - "polygon", - "g", - "defs", - "use", - "text", - "tspan", - "clipPath", - "mask", - ]); - - function appendChild(el, child) { - if (child === null || child === undefined || child === false) { - return; - } - - if (Array.isArray(child)) { - child.forEach((entry) => appendChild(el, entry)); - return; - } - - if (typeof child === "string" || typeof child === "number") { - el.appendChild(document.createTextNode(String(child))); - return; - } - - if (child instanceof Node) { - el.appendChild(child); - } - } - - function h(tag, props = {}, ...children) { - const isSvg = SVG_TAGS.has(tag); - const el = isSvg - ? document.createElementNS(SVG_NS, tag) - : document.createElement(tag); - - if (props) { - Object.entries(props).forEach(([key, value]) => { - if (key.startsWith("on") && typeof value === "function") { - el.addEventListener(key.substring(2).toLowerCase(), value); - return; - } - - if (key === "className") { - if (isSvg) { - el.setAttribute("class", value); - } else { - el.className = value; - } - return; - } - - if (key === "style" && typeof value === "object") { - Object.assign(el.style, value); - return; - } - - if (key === "value" && "value" in el) { - el.value = value; - return; - } - - if (key === "checked" && "checked" in el) { - el.checked = Boolean(value); - return; - } - - if (typeof value === "boolean") { - if (value) { - el.setAttribute(key, ""); - } else { - el.removeAttribute(key); - } - return; - } - - if (value === null || value === undefined) { - el.removeAttribute(key); - return; - } - - el.setAttribute(key, value); - }); - } - - children.forEach((child) => appendChild(el, child)); - return el; - } - - let rootContainer = null; - 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; - rerender(); - } - - function rerender() { - if (!rootContainer || !rootComponent) { - return; - } - - const scrollState = captureScrollState(rootContainer); - rootContainer.innerHTML = ""; - rootContainer.appendChild(rootComponent()); - restoreScrollState(rootContainer, scrollState); - } - - function ensureScopedStyle(id, cssText) { - if (!id || !cssText || injectedStyles.has(id)) { - return; - } - - const style = document.createElement("style"); - style.setAttribute("data-ui-style", id); - style.textContent = cssText; - document.head.appendChild(style); - injectedStyles.add(id); - } - - function createSignal(initialValue) { - let value = initialValue; - - const getValue = () => value; - const setValue = (nextValue) => { - value = - typeof nextValue === "function" ? nextValue(value) : nextValue; - rerender(); - }; - - return [getValue, setValue]; - } - - const runtime = { - h, - render, - rerender, - createSignal, - ensureScopedStyle, - }; - - StorefrontApp.runtime = runtime; - window.AppRuntime = runtime; -})(); diff --git a/arma/client/addons/store/ui/_site/script.js b/arma/client/addons/store/ui/_site/script.js deleted file mode 100644 index be65046..0000000 --- a/arma/client/addons/store/ui/_site/script.js +++ /dev/null @@ -1,25 +0,0 @@ -(function () { - function mountStorefront() { - const root = document.getElementById("app"); - if (!root) { - return; - } - - window.StorefrontApp.runtime.render( - window.StorefrontApp.components.App, - root, - ); - - if (window.StorefrontApp.bridge) { - window.StorefrontApp.bridge.notifyReady(); - } - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", mountStorefront, { - once: true, - }); - } else { - mountStorefront(); - } -})(); diff --git a/arma/client/addons/store/ui/_site/style.css b/arma/client/addons/store/ui/_site/store-ui.css similarity index 92% rename from arma/client/addons/store/ui/_site/style.css rename to arma/client/addons/store/ui/_site/store-ui.css index 8bc7f13..10869de 100644 --- a/arma/client/addons/store/ui/_site/style.css +++ b/arma/client/addons/store/ui/_site/store-ui.css @@ -1,6 +1,5 @@ +/* Generated by tools/build-webui.mjs for Store UI styles. Do not edit directly. */ :root { - --store-titlebar-bg: linear-gradient(180deg, #173a63 0%, #0e2c4f 100%); - --store-titlebar-border: rgba(161, 190, 224, 0.18); --store-shell-bg: #e4e3df; --store-surface: #f5f3ef; --store-surface-alt: #ece8e2; diff --git a/arma/client/addons/store/ui/_site/store-ui.js b/arma/client/addons/store/ui/_site/store-ui.js new file mode 100644 index 0000000..7fba4b4 --- /dev/null +++ b/arma/client/addons/store/ui/_site/store-ui.js @@ -0,0 +1,3487 @@ +/* Generated by tools/build-webui.mjs for Store UI app. Do not edit directly. */ +(function () { + const runtime = window.ForgeWebUI; + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + StorefrontApp.runtime = runtime; + window.AppRuntime = runtime; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const runtime = StorefrontApp.runtime; + const [getTextureVersion, setTextureVersion] = runtime.createSignal(0); + 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; + setTextureVersion((currentVersion) => currentVersion + 1); + }, 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) { + getTextureVersion(); + const normalizedPath = normalizeTexturePath(path); + return { + path: normalizedPath, + isVisible: Boolean( + normalizedPath && visibleTexturePaths[normalizedPath], + ), + isLoaded: Boolean( + normalizedPath && + textureCache[normalizedPath] && + isBrowserTextureSource(textureCache[normalizedPath]), + ), + }; + } + + function getTextureSource(path) { + getTextureVersion(); + 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, + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + + const defaultSession = { + actorName: "", + actorUid: "", + approval: "Field Access", + orgId: "", + orgName: "", + orgLeader: false, + defaultOrgCeo: false, + canUseOrgFunds: false, + }; + + const defaultStoreConfig = { + budget: 50000, + creditLine: 0, + availability: "In-Stock", + moduleState: "Preview", + searchTags: [ + "Attachment", + "Grenade", + "Medical", + "Consumable", + "Static", + "Scope", + "Item", + "Misc", + ], + paymentSources: [ + { + id: "cash", + label: "Cash", + balance: 0, + enabled: false, + detail: "Use on-hand cash carried by the player.", + }, + { + id: "bank", + label: "Bank", + balance: 0, + enabled: false, + detail: "Charge the player bank account.", + }, + { + id: "org_funds", + label: "Org Funds", + balance: 0, + enabled: false, + detail: "Only organization leaders or the default-org CEO can use treasury funds.", + }, + { + id: "credit_line", + label: "Credit Line", + balance: 0, + enabled: false, + detail: "No approved credit line is assigned to this member.", + }, + ], + defaultPaymentSource: "cash", + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + const catalog = { + categoryCards: [ + { id: "uniforms", label: "Uniforms" }, + { id: "headgear", label: "Headgear" }, + { id: "facewear", label: "Facewear" }, + { id: "vests", label: "Vests" }, + { id: "weapons", label: "Weapons" }, + { id: "ammo", label: "Ammo" }, + { id: "items", label: "Items" }, + { id: "vehicles", label: "Vehicles" }, + ], + vehicleCards: [ + { id: "cars", label: "Cars" }, + { id: "armor", label: "Armor" }, + { id: "helis", label: "Helicopters" }, + { id: "planes", label: "Planes" }, + { id: "naval", label: "Naval" }, + { id: "other", label: "Other" }, + ], + weaponCards: [ + { id: "primary", label: "Primary" }, + { id: "secondary", label: "Secondary" }, + { id: "handgun", label: "Handgun" }, + ], + previewItems: { + uniforms: [], + headgear: [], + facewear: [], + vests: [], + ammo: [], + items: [], + primary: [], + secondary: [], + handgun: [], + cars: [], + armor: [], + helis: [], + planes: [], + naval: [], + other: [], + }, + }; + + StorefrontApp.data = { + catalog, + session: Object.assign({}, defaultSession), + storeConfig: Object.assign({}, defaultStoreConfig), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.storeConfig, + Object.assign( + {}, + defaultStoreConfig, + payload?.storeConfig || {}, + ), + ); + }, + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { createSignal } = StorefrontApp.runtime; + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + SharedLogic.createStorefrontStore = function createStorefrontStore({ + createSignal, + }) { + function normalizeCatalogItem(item) { + return { + 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 || ""), + category: String(item?.category || ""), + entryKind: String(item?.entryKind || "item"), + quantity: Math.max(0, Number(item?.quantity || 0)), + }; + } + + function normalizeCartItem(item) { + return { + code: String(item?.code || ""), + name: String(item?.name || ""), + price: String(item?.price || "$0"), + category: String(item?.category || ""), + entryKind: String(item?.entryKind || "item"), + quantity: Math.max(1, Number(item?.quantity || 1)), + }; + } + + class StorefrontStore { + constructor() { + [this.getView, this.setView] = createSignal("categories"); + [this.getSelectedCategory, this.setSelectedCategory] = + createSignal(""); + [this.getSelectedWeaponSlot, this.setSelectedWeaponSlot] = + createSignal(""); + [this.getSelectedVehicleSlot, this.setSelectedVehicleSlot] = + createSignal(""); + [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: "", + }); + [this.getIsCheckingOut, this.setIsCheckingOut] = + createSignal(false); + [this.getSelectedPaymentSource, this.setSelectedPaymentSource] = + createSignal("cash"); + } + + resetToCategories() { + this.setView("categories"); + this.setSelectedCategory(""); + this.setSelectedWeaponSlot(""); + this.setSelectedVehicleSlot(""); + this.setIsCatalogLoading(false); + this.setCatalogRequestKey(""); + this.setCatalogPage(1); + } + + openWeaponsRoot() { + this.setView("weapons"); + this.setSelectedCategory("weapons"); + this.setSelectedWeaponSlot(""); + this.setSelectedVehicleSlot(""); + this.setIsCatalogLoading(false); + this.setCatalogRequestKey(""); + this.setCatalogPage(1); + } + + openVehiclesRoot() { + this.setView("vehicles"); + 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(); + return; + } + + if (category === "vehicles") { + this.openVehiclesRoot(); + return; + } + + this.setView("items"); + } + + selectSubcategory(subcategory, slotType) { + if (slotType === "vehicle") { + this.setSelectedVehicleSlot(subcategory); + this.setSelectedWeaponSlot(""); + } else { + this.setSelectedWeaponSlot(subcategory); + 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(normalizeCatalogItem), + }), + ); + + this.finishCategoryRequest(categoryKey); + } + + ensureSelectedPaymentSource(storeConfig) { + const paymentSources = Array.isArray( + storeConfig?.paymentSources, + ) + ? storeConfig.paymentSources + : []; + const currentSource = String( + this.getSelectedPaymentSource() || "", + ).trim(); + const defaultSource = String( + storeConfig?.defaultPaymentSource || "", + ).trim(); + const sourceIds = paymentSources.map((source) => + String(source?.id || "").trim(), + ); + const enabledSource = paymentSources.find( + (source) => source && source.enabled !== false, + ); + const defaultAvailable = + defaultSource && sourceIds.includes(defaultSource) + ? paymentSources.find( + (source) => + String(source?.id || "").trim() === + defaultSource, + ) + : null; + + if ( + currentSource && + sourceIds.includes(currentSource) && + paymentSources.some( + (source) => + String(source?.id || "").trim() === currentSource && + source?.enabled !== false, + ) + ) { + return; + } + + if (defaultAvailable && defaultAvailable.enabled !== false) { + this.setSelectedPaymentSource(defaultSource); + return; + } + + if (enabledSource) { + this.setSelectedPaymentSource( + String(enabledSource.id || "cash"), + ); + return; + } + + this.setSelectedPaymentSource(defaultSource || "cash"); + } + + navigateToBreadcrumb(target) { + switch (target) { + case "categories": + this.resetToCategories(); + return true; + case "weapons": + this.openWeaponsRoot(); + return true; + case "vehicles": + this.openVehiclesRoot(); + return true; + default: + return false; + } + } + + hydrateFromPayload(payload) { + const cartItems = Array.isArray(payload?.cartItems) + ? payload.cartItems + : []; + + this.setCartItems(cartItems.map(normalizeCartItem)); + this.setCartOpen(false); + this.setIsCheckingOut(false); + this.setCatalogItemsByKey({}); + this.setCatalogRequestKey(""); + this.setIsCatalogLoading(false); + this.setCatalogPage(1); + this.ensureSelectedPaymentSource(payload?.storeConfig || {}); + } + + hydrateStoreConfig(payload) { + const cartItems = Array.isArray(payload?.cartItems) + ? payload.cartItems + : []; + + this.setCartItems(cartItems.map(normalizeCartItem)); + this.setCartOpen(false); + this.setIsCheckingOut(false); + this.ensureSelectedPaymentSource(payload?.storeConfig || {}); + } + } + + return new StorefrontStore(); + }; + + StorefrontApp.store = SharedLogic.createStorefrontStore({ + createSignal, + }); +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const CATALOG_PAGE_SIZE = 6; + + function getSelectionKey(state) { + return ( + state.selectedWeaponSlot || + state.selectedVehicleSlot || + state.selectedCategory + ); + } + + function matchesQuery(query, values) { + if (!query) { + return true; + } + + const normalizedQuery = String(query).trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + return values.some((value) => + String(value || "") + .toLowerCase() + .includes(normalizedQuery), + ); + } + + function parsePrice(value) { + const parsed = Number(String(value || "0").replace(/[^0-9.-]+/g, "")); + return Number.isFinite(parsed) ? parsed : 0; + } + + function formatCurrency(value) { + return `$${Number(value || 0).toLocaleString()}`; + } + + function formatTitle(value) { + return String(value || "") + .replace(/[-_]+/g, " ") + .split(/\s+/) + .filter(Boolean) + .map( + (part) => + part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(), + ) + .join(" "); + } + + function getStoreState(store) { + return { + view: store.getView(), + selectedCategory: store.getSelectedCategory(), + selectedWeaponSlot: store.getSelectedWeaponSlot(), + selectedVehicleSlot: store.getSelectedVehicleSlot(), + selectedPaymentSource: store.getSelectedPaymentSource(), + cartOpen: store.getCartOpen(), + searchQuery: store.getSearchQuery(), + cartItems: store.getCartItems(), + catalogItemsByKey: store.getCatalogItemsByKey(), + isCatalogLoading: store.getIsCatalogLoading(), + catalogRequestKey: store.getCatalogRequestKey(), + catalogPage: store.getCatalogPage(), + isCheckingOut: store.getIsCheckingOut(), + }; + } + + function getStoreHeader(state) { + if (state.view === "weapons") { + return { + eyebrow: "Weapons Division", + title: "Weapon Categories", + copy: "Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are staged with the same state and bridge flow as the org portal.", + badge: "3 Slots", + }; + } + + if (state.view === "vehicles") { + return { + eyebrow: "Vehicle Motorpool", + title: "Vehicle Categories", + copy: "Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options stay inside the same local store and bridge lifecycle.", + badge: "6 Classes", + }; + } + + if (state.view === "items") { + const label = getSelectionKey(state) || "catalog"; + 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: `Live category inventory generated from the game engine for the selected department.${queryLabel}${loadingLabel}`, + badge: "Preview Items", + }; + } + + return { + eyebrow: "Supply Categories", + title: "Procurement Dashboard", + copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.", + badge: "8 Categories", + }; + } + + function getStoreBreadcrumbs(state) { + const items = [{ id: "categories", label: "Supply Exchange" }]; + + if (state.view === "weapons") { + items.push({ id: "weapons", label: "Weapons" }); + return items; + } + + if (state.view === "vehicles") { + items.push({ id: "vehicles", label: "Vehicles" }); + return items; + } + + if (state.view === "items") { + if (state.selectedWeaponSlot) { + items.push({ id: "weapons", label: "Weapons" }); + items.push({ + id: "weapon-slot", + label: formatTitle(state.selectedWeaponSlot), + }); + return items; + } + + if (state.selectedVehicleSlot) { + items.push({ id: "vehicles", label: "Vehicles" }); + items.push({ + id: "vehicle-slot", + label: formatTitle(state.selectedVehicleSlot), + }); + return items; + } + + if (state.selectedCategory) { + items.push({ + id: "category", + label: formatTitle(state.selectedCategory), + }); + } + } + + return items; + } + + function getVisibleCategoryCards(state, catalog) { + return catalog.categoryCards.filter((category) => + matchesQuery(state.searchQuery, [category.id, category.label]), + ); + } + + function getVisibleSubcategoryCards(state, catalog) { + const source = + state.view === "vehicles" + ? catalog.vehicleCards + : catalog.weaponCards; + + return source.filter((category) => + matchesQuery(state.searchQuery, [category.id, category.label]), + ); + } + + function getVisibleItems(state, catalog) { + const key = getSelectionKey(state); + 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), + 0, + ); + const subtotal = cartItems.reduce( + (sum, item) => + sum + parsePrice(item.price) * Number(item.quantity || 0), + 0, + ); + + return { + lineCount: cartItems.length, + itemCount, + subtotal, + total: subtotal, + }; + } + + function getPaymentSources(storeConfig) { + const paymentSources = Array.isArray(storeConfig?.paymentSources) + ? storeConfig.paymentSources + : []; + + return paymentSources.map((source) => ({ + id: String(source?.id || "").trim(), + label: String(source?.label || source?.id || "").trim(), + balance: Number(source?.balance || 0), + enabled: source?.enabled !== false, + detail: String(source?.detail || "").trim(), + })); + } + + function getPaymentSourceById(storeConfig, paymentSourceId) { + const sourceId = String(paymentSourceId || "").trim(); + return getPaymentSources(storeConfig).find( + (source) => source.id === sourceId, + ); + } + + StorefrontApp.getters = { + formatTitle, + formatCurrency, + parsePrice, + getSelectionKey, + getStoreState, + getStoreHeader, + getStoreBreadcrumbs, + getVisibleCategoryCards, + getVisibleSubcategoryCards, + getVisibleItems, + getVisibleItemsPage, + getCatalogPagination, + summarizeCart, + getPaymentSources, + getPaymentSourceById, + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const store = StorefrontApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "store::close", + globalName: "StoreUIBridge", + readyEvent: "store::ready", + }); + + function requestClose() { + return bridge.close({}); + } + + function requestCheckout(payload) { + return bridge.send("store::checkout::request", payload); + } + + function requestCategory(payload) { + return bridge.send("store::category::request", payload); + } + + function notifyReady() { + return bridge.ready({ loaded: true }); + } + + bridge.on("store::hydrate", (payloadData) => { + StorefrontApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + }); + + bridge.on("store::config::hydrate", (payloadData) => { + StorefrontApp.data.applyHydratePayload(payloadData); + store.hydrateStoreConfig(payloadData); + }); + + bridge.on("store::checkout::success", (payloadData) => { + store.setIsCheckingOut(false); + store.setCartItems([]); + store.setCartOpen(false); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "success", + payloadData.message || "Checkout completed.", + ); + } + }); + + bridge.on("store::category::hydrate", (payloadData) => { + store.hydrateCategoryItems(payloadData); + }); + + bridge.on("store::category::failure", (payloadData) => { + store.finishCategoryRequest(payloadData.category || ""); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "error", + payloadData.message || "Category request failed.", + ); + } + }); + + bridge.on("store::checkout::failure", (payloadData) => { + store.setIsCheckingOut(false); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "error", + payloadData.message || "Checkout failed.", + ); + } + }); + + StorefrontApp.bridge = { + close: bridge.close, + requestClose, + requestCheckout, + requestCategory, + notifyReady, + receive: bridge.receive, + sendEvent: bridge.send, + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const store = StorefrontApp.store; + const getters = StorefrontApp.getters; + const { storeConfig, session } = StorefrontApp.data; + + let noticeTimer = null; + + function showNotice(type, text) { + store.setNotice({ type, text }); + + if (noticeTimer) { + clearTimeout(noticeTimer); + } + + noticeTimer = setTimeout(() => { + store.setNotice({ type: "", text: "" }); + noticeTimer = null; + }, 3200); + } + + function normalizeCheckoutItem(item) { + return { + classname: String(item?.code || "").trim(), + category: String(item?.category || "") + .trim() + .toLowerCase(), + entryKind: String(item?.entryKind || "item") + .trim() + .toLowerCase(), + quantity: Math.max(1, Number(item?.quantity || 1)), + }; + } + + function buildCheckoutPayload(cartItems, paymentMethod, totalPrice) { + const payload = { + items: [], + vehicles: [], + totalPrice, + paymentMethod, + }; + + cartItems.forEach((item) => { + const normalizedItem = normalizeCheckoutItem(item); + + if (normalizedItem.entryKind === "vehicle") { + for ( + let index = 0; + index < normalizedItem.quantity; + index += 1 + ) { + payload.vehicles.push({ + classname: normalizedItem.classname, + category: normalizedItem.category, + }); + } + return; + } + + payload.items.push({ + classname: normalizedItem.classname, + category: normalizedItem.category, + quantity: normalizedItem.quantity, + }); + }); + + return payload; + } + + function applySearchQuery(value) { + store.setSearchQuery(String(value || "").trim()); + store.resetCatalogPage(); + } + + function clearSearch() { + store.setSearchQuery(""); + store.resetCatalogPage(); + } + + function toggleCart() { + store.setCartOpen((open) => !open); + } + + function closeCart() { + store.setCartOpen(false); + } + + function closeStore() { + const bridge = StorefrontApp.bridge; + if (bridge && typeof bridge.requestClose === "function") { + const sent = bridge.requestClose(); + if (sent) { + return true; + } + } + + showNotice("error", "Store bridge is unavailable."); + return false; + } + + function navigateToBreadcrumb(target) { + 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) { + store.setCartItems((currentItems) => { + const existingIndex = currentItems.findIndex( + (entry) => entry.code === item.code, + ); + if (existingIndex === -1) { + return [ + ...currentItems, + { + code: item.code, + name: item.name, + price: item.price, + category: item.category, + entryKind: item.entryKind, + quantity: 1, + }, + ]; + } + + const nextItems = [...currentItems]; + nextItems[existingIndex] = Object.assign( + {}, + nextItems[existingIndex], + { + category: item.category, + entryKind: item.entryKind, + quantity: nextItems[existingIndex].quantity + 1, + }, + ); + return nextItems; + }); + + showNotice("success", `${item.name} added to the acquisition queue.`); + } + + function incrementCartItem(code) { + store.setCartItems((currentItems) => + currentItems.map((item) => + item.code === code + ? Object.assign({}, item, { quantity: item.quantity + 1 }) + : item, + ), + ); + } + + function decrementCartItem(code) { + store.setCartItems((currentItems) => + currentItems + .map((item) => + item.code === code + ? Object.assign({}, item, { + quantity: Math.max(0, item.quantity - 1), + }) + : item, + ) + .filter((item) => item.quantity > 0), + ); + } + + function removeCartItem(code) { + store.setCartItems((currentItems) => + currentItems.filter((item) => item.code !== code), + ); + } + + function selectPaymentSource(paymentSourceId) { + const sourceId = String(paymentSourceId || "").trim(); + const paymentSources = getters.getPaymentSources(storeConfig); + const selectedSource = paymentSources.find( + (source) => source.id === sourceId, + ); + + if (!selectedSource) { + showNotice("error", "Selected payment source is unavailable."); + return false; + } + + if (selectedSource.enabled === false) { + showNotice( + "error", + selectedSource.detail || + "Selected payment source is not available.", + ); + return false; + } + + store.setSelectedPaymentSource(sourceId); + return true; + } + + function requestCheckout() { + const cartItems = store.getCartItems(); + if (cartItems.length === 0) { + showNotice("error", "Add at least one item before checkout."); + return false; + } + + const summary = getters.summarizeCart(cartItems); + const selectedPaymentSource = getters.getPaymentSourceById( + storeConfig, + store.getSelectedPaymentSource(), + ); + + if (!selectedPaymentSource) { + showNotice("error", "Select a payment source before checkout."); + return false; + } + + if (selectedPaymentSource.enabled === false) { + showNotice( + "error", + selectedPaymentSource.detail || + "Selected payment source is unavailable.", + ); + return false; + } + + if (summary.total > Number(selectedPaymentSource.balance || 0)) { + showNotice( + "error", + `${selectedPaymentSource.label} cannot cover this checkout total.`, + ); + return false; + } + + const bridge = StorefrontApp.bridge; + if (!bridge || typeof bridge.requestCheckout !== "function") { + showNotice("error", "Checkout bridge is unavailable."); + return false; + } + + store.setIsCheckingOut(true); + + const checkoutPayload = buildCheckoutPayload( + cartItems, + selectedPaymentSource.id, + summary.total, + ); + + const sent = bridge.requestCheckout({ + checkoutJson: JSON.stringify(checkoutPayload), + }); + + if (!sent) { + store.setIsCheckingOut(false); + showNotice("error", "Checkout bridge is unavailable."); + return false; + } + + return true; + } + + StorefrontApp.actions = { + showNotice, + applySearchQuery, + clearSearch, + toggleCart, + closeCart, + closeStore, + navigateToBreadcrumb, + selectCategory, + selectSubcategory, + goToCatalogPage, + goToNextCatalogPage, + goToPreviousCatalogPage, + addToCart, + incrementCartItem, + decrementCartItem, + removeCartItem, + selectPaymentSource, + requestCheckout, + formatTitle: getters.formatTitle, + formatCurrency: getters.formatCurrency, + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const store = StorefrontApp.store; + const getters = StorefrontApp.getters; + const actions = StorefrontApp.actions; + const { catalog, session, storeConfig } = StorefrontApp.data; + const scopeAttr = "data-ui-store-app-shell"; + const scopeSelector = `[${scopeAttr}]`; + const appShellCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--store-shell-bg); +} + +${scopeSelector} .footer-title, +${scopeSelector} .eyebrow { + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--store-text-subtle); + font-weight: 700; +} + +${scopeSelector} .module-header, +${scopeSelector} .store-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +${scopeSelector} .store-app { + flex: 1; + min-height: 0; + width: min(100%, 1613px); + margin: 0 auto; + display: grid; + grid-template-columns: 308px minmax(0, 1fr); + gap: 1.25rem; + padding: 1.25rem; +} + +${scopeSelector} .store-sidebar, +${scopeSelector} .store-main { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +${scopeSelector} .store-main { + position: relative; + overflow: hidden; +} + +${scopeSelector} .module-card, +${scopeSelector} .store-panel { + background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%); + border: 1px solid var(--store-border); + border-radius: 1.35rem; +} + +${scopeSelector} .module-card { + padding: 1rem; +} + +${scopeSelector} .store-panel { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + width: min(100%, 1280px); + overflow: hidden; +} + +${scopeSelector} .module-header { + margin-bottom: 0.85rem; +} + +${scopeSelector} .store-panel-header { + padding: 1rem 1rem 0; +} + +${scopeSelector} .section-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--store-text-main); +} + +${scopeSelector} .section-copy, +${scopeSelector} .footer-copy { + margin: 0.2rem 0 0; + font-size: 0.9rem; + line-height: 1.45; + color: var(--store-text-muted); +} + +${scopeSelector} .pill { + padding: 0.48rem 0.8rem; + border-radius: 999px; + background: var(--store-accent-soft); + color: var(--store-accent); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +${scopeSelector} .search-module { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +${scopeSelector} .search-form { + display: grid; + gap: 0.7rem; +} + +${scopeSelector} .search-input { + width: 100%; + height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.75); + color: var(--store-text-main); +} + +${scopeSelector} .quick-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +${scopeSelector} .quick-tag { + padding: 0.55rem 0.72rem; + border-radius: 999px; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.52); + color: var(--store-text-muted); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +${scopeSelector} .filter-stack { + display: grid; + gap: 0.85rem; +} + +${scopeSelector} .filter-group { + padding: 0.95rem; + border-radius: 0.8rem; + background: rgb(255 255 255 / 0.48); + border: 1px solid var(--store-border); +} + +${scopeSelector} .filter-label { + display: block; + margin-bottom: 0.55rem; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--store-text-subtle); + font-weight: 700; +} + +${scopeSelector} .filter-value { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: var(--store-text-main); + font-size: 0.92rem; + font-weight: 600; +} + +${scopeSelector} .filter-placeholder { + color: var(--store-text-muted); + font-weight: 500; +} + +${scopeSelector} .store-panel-intro { + padding: 0 1rem 1rem; + border-bottom: 1px solid var(--store-accent-line); +} + +${scopeSelector} .store-footer { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + padding: 0.95rem 1.25rem 1.15rem; + border-top: 1px solid rgb(18 54 93 / 0.1); + background: transparent; +} + +${scopeSelector} .footer-block { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +${scopeSelector} .store-toast-stack { + position: fixed; + top: 1.2rem; + right: 1.5rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +${scopeSelector} .store-toast { + max-width: 24rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + border: 1px solid var(--store-border); + background: #fff; + box-shadow: 0 14px 28px rgb(16 34 56 / 0.14); + font-size: 0.92rem; +} + +${scopeSelector} .store-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + +${scopeSelector} .store-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +@media (max-width: 1440px) { + ${scopeSelector} .store-app { + grid-template-columns: 284px minmax(0, 1fr); + } +} + +@media (max-width: 1120px) { + ${scopeSelector} .store-app { + grid-template-columns: 1fr; + overflow: auto; + } + + ${scopeSelector} .store-sidebar, + ${scopeSelector} .store-main { + min-height: auto; + } + + ${scopeSelector} .store-main { + overflow: visible; + } + + ${scopeSelector} .store-footer { + grid-template-columns: 1fr; + } + + ${scopeSelector} .store-toast-stack { + right: 1rem; + left: 1rem; + } +} +`; + + StorefrontApp.components = StorefrontApp.components || {}; + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + function renderStoreBody(state) { + const { + CategoryCard, + SubcategoryCard, + ProductCard, + EmptyStateCard, + CategoryGrid, + SubcategoryGrid, + ProductGrid, + CatalogPager, + } = StorefrontApp.componentFns; + + if (state.view === "weapons" || state.view === "vehicles") { + const slotType = state.view === "vehicles" ? "vehicle" : "weapon"; + const items = getters.getVisibleSubcategoryCards(state, catalog); + + return SubcategoryGrid( + items.length > 0 + ? items.map((category) => + SubcategoryCard(category, slotType), + ) + : EmptyStateCard({ + title: "No matching slots", + copy: "Try a different search query or clear the current filter.", + actionLabel: "Clear Search", + onAction: () => actions.clearSearch(), + }), + ); + } + + 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( + 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); + return CategoryGrid( + items.length > 0 + ? items.map((category) => CategoryCard(category)) + : EmptyStateCard({ + title: "No matching departments", + copy: "Your search filter excluded every top-level department.", + actionLabel: "Clear Search", + onAction: () => actions.clearSearch(), + }), + ); + } + + StorefrontApp.components.App = function App() { + const Navbar = StorefrontApp.componentFns.Navbar; + const Cart = StorefrontApp.componentFns.Cart; + const state = getters.getStoreState(store); + const header = getters.getStoreHeader(state); + const notice = store.getNotice(); + const activeQuery = state.searchQuery; + const paymentSources = getters.getPaymentSources(storeConfig); + const availablePaymentSourceCount = paymentSources.filter( + (source) => source.enabled !== false, + ).length; + const filterDepartment = + state.view === "items" + ? actions.formatTitle( + getters.getSelectionKey(state) || "Catalog", + ) + : actions.formatTitle(state.view); + const selectedPaymentSource = + getters.getPaymentSourceById( + storeConfig, + state.selectedPaymentSource, + ) || null; + + ensureScopedStyle("storefront-app-shell", appShellCss); + + return h( + "div", + { [scopeAttr]: "" }, + WindowTitleBar({ + kicker: "FORGE Logistics", + title: "Supply Exchange", + onClose: () => actions.closeStore(), + closeLabel: "Close store interface", + }), + notice.text + ? h( + "div", + { className: "store-toast-stack" }, + h( + "div", + { + className: + notice.type === "error" + ? "store-toast is-error" + : "store-toast is-success", + }, + notice.text, + ), + ) + : null, + h( + "div", + { className: "store-app" }, + h( + "aside", + { className: "store-sidebar" }, + h( + "section", + { className: "module-card search-module" }, + h( + "div", + { className: "module-header" }, + h( + "div", + null, + h("span", { className: "eyebrow" }, "Search"), + h( + "h2", + { className: "section-title" }, + "Inventory Search", + ), + ), + h("span", { className: "pill" }, "Live"), + ), + h( + "div", + { className: "search-form" }, + h("input", { + id: "store-search-input", + type: "text", + className: "search-input", + placeholder: + "Search inventory, classes, or suppliers", + value: activeQuery, + }), + h( + "div", + { + style: { + display: "flex", + gap: "0.65rem", + }, + }, + h( + "button", + { + type: "button", + className: + "store-btn store-btn-primary", + onClick: () => + actions.applySearchQuery( + document.getElementById( + "store-search-input", + )?.value || "", + ), + }, + "Apply Search", + ), + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary", + onClick: () => actions.clearSearch(), + }, + "Clear", + ), + ), + ), + h( + "div", + { className: "quick-tags" }, + (storeConfig.searchTags || []).map((tag) => + h("span", { className: "quick-tag" }, tag), + ), + ), + ), + h( + "section", + { className: "module-card" }, + h( + "div", + { className: "module-header" }, + h( + "div", + null, + h("span", { className: "eyebrow" }, "Filter"), + h( + "h2", + { className: "section-title" }, + "Procurement Filters", + ), + ), + h( + "span", + { className: "pill" }, + storeConfig.moduleState, + ), + ), + h( + "div", + { className: "filter-stack" }, + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Department", + ), + h( + "div", + { className: "filter-value" }, + h( + "span", + { className: "filter-placeholder" }, + filterDepartment, + ), + ), + ), + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Availability", + ), + h( + "div", + { className: "filter-value" }, + h( + "span", + { className: "filter-placeholder" }, + storeConfig.availability, + ), + ), + ), + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Payment", + ), + h( + "div", + { className: "filter-value" }, + h( + "span", + { className: "filter-placeholder" }, + selectedPaymentSource + ? selectedPaymentSource.label + : "Cash", + ), + ), + ), + ), + ), + ), + h( + "main", + { className: "store-main" }, + h( + "section", + { className: "store-panel" }, + Navbar(), + h( + "div", + { className: "store-panel-header" }, + h( + "div", + null, + h( + "span", + { className: "eyebrow" }, + header.eyebrow, + ), + h( + "h1", + { className: "section-title" }, + header.title, + ), + ), + h("span", { className: "pill" }, header.badge), + ), + h( + "div", + { className: "store-panel-intro" }, + h("p", { className: "section-copy" }, header.copy), + ), + renderStoreBody(state), + ), + Cart(), + ), + ), + h( + "footer", + { className: "store-footer" }, + h( + "div", + { className: "footer-block" }, + h( + "span", + { className: "footer-title" }, + "Procurement Desk", + ), + h( + "span", + { className: "footer-copy" }, + "Authorized supply browsing for personnel loadout preparation and mission staging.", + ), + ), + h( + "div", + { className: "footer-block" }, + h("span", { className: "footer-title" }, "Catalog Scope"), + h( + "span", + { className: "footer-copy" }, + "Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.", + ), + ), + h( + "div", + { className: "footer-block" }, + h("span", { className: "footer-title" }, "Purchase Access"), + h( + "span", + { className: "footer-copy" }, + `${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`, + ), + ), + ), + ); + }; +})(); + +(function () { + 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; + align-content: start; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: auto; + scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.45); +} + +${scopeSelector} .catalog-grid::-webkit-scrollbar { + width: 12px; +} + +${scopeSelector} .catalog-grid::-webkit-scrollbar-track { + background: rgb(255 255 255 / 0.45); + border-radius: 999px; +} + +${scopeSelector} .catalog-grid::-webkit-scrollbar-thumb { + background: rgb(120 136 155 / 0.9); + border-radius: 999px; + border: 2px solid rgb(255 255 255 / 0.45); +} + +${scopeSelector} .catalog-grid.is-categories, +${scopeSelector} .catalog-grid.is-products { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +${scopeSelector} .catalog-grid.is-subcategories { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +${scopeSelector} .card-button, +${scopeSelector} .product-card, +${scopeSelector} .empty-state { + border: 1px solid var(--store-border); + border-radius: 1.15rem; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(226 233 239 / 0.9) 100%), + var(--store-surface-strong); + color: var(--store-accent); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.8), + 0 10px 24px rgb(16 34 56 / 0.06); +} + +${scopeSelector} .card-button { + min-height: 12.5rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.75rem; + padding: 1.35rem; + text-align: left; + transition: + transform 120ms ease, + box-shadow 120ms ease, + border-color 120ms ease; +} + +${scopeSelector} .card-button:hover, +${scopeSelector} .product-card:hover { + transform: translateY(-2px); + border-color: rgb(18 54 93 / 0.32); + box-shadow: + 0 16px 28px rgb(16 34 56 / 0.11), + inset 0 1px 0 rgb(255 255 255 / 0.88); +} + +${scopeSelector} .card-kicker, +${scopeSelector} .product-code, +${scopeSelector} .empty-state-kicker { + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 700; + color: var(--store-text-subtle); +} + +${scopeSelector} .card-label { + font-size: 1.08rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +${scopeSelector} .card-copy, +${scopeSelector} .product-copy, +${scopeSelector} .empty-state-copy { + margin: 0; + color: var(--store-text-muted); + line-height: 1.45; +} + +${scopeSelector} .product-copy { + white-space: pre-line; +} + +${scopeSelector} .product-card { + min-height: 15.5rem; + padding: 0.8rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +${scopeSelector} .product-image { + 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%); + display: flex; + align-items: center; + justify-content: center; + color: var(--store-text-subtle); + 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.2rem; +} + +${scopeSelector} .product-name { + font-size: 0.96rem; + font-weight: 700; + color: var(--store-text-main); + line-height: 1.3; +} + +${scopeSelector} .product-footer { + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .product-price { + font-size: 0.96rem; + font-weight: 700; + color: var(--store-success); +} + +${scopeSelector} .product-qty { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.85rem; + height: 1.85rem; + border-radius: 999px; + background: var(--store-accent-soft); + color: var(--store-accent); + font-size: 0.76rem; + font-weight: 700; +} + +${scopeSelector} .empty-state { + padding: 1.35rem; + display: flex; + flex-direction: column; + 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 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + ${scopeSelector} .catalog-grid.is-categories, + ${scopeSelector} .catalog-grid.is-subcategories, + ${scopeSelector} .catalog-grid.is-products { + grid-template-columns: 1fr; + } +} +`; + + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + function createGrid(className, children) { + ensureScopedStyle("storefront-cards", cardsCss); + + if ( + className === "is-products" && + media && + typeof media.scheduleTextureObservation === "function" + ) { + media.scheduleTextureObservation(); + } + + return h( + "div", + { + [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", + { + type: "button", + className: "card-button", + onClick: () => actions.selectCategory(category.id), + }, + h("span", { className: "card-kicker" }, "Department"), + h("strong", { className: "card-label" }, category.label), + h( + "p", + { className: "card-copy" }, + "Open this department and move into staged inventory browsing.", + ), + ); + }; + + StorefrontApp.componentFns.SubcategoryCard = function SubcategoryCard( + category, + slotType, + ) { + return h( + "button", + { + type: "button", + className: "card-button", + onClick: () => actions.selectSubcategory(category.id, slotType), + }, + h( + "span", + { className: "card-kicker" }, + slotType === "vehicle" ? "Vehicle Class" : "Weapon Slot", + ), + h("strong", { className: "card-label" }, category.label), + h( + "p", + { className: "card-copy" }, + "Open the next tier and review product previews for this selection.", + ), + ); + }; + + StorefrontApp.componentFns.ProductCard = function ProductCard( + 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", + "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.type || item.code || item.className, + ), + h("strong", { className: "product-name" }, item.name), + ), + h("p", { className: "product-copy" }, description), + h( + "div", + { className: "product-footer" }, + h( + "span", + { className: "product-price" }, + item.price || "Pending", + ), + h( + "div", + { + style: { + display: "flex", + alignItems: "center", + gap: "0.55rem", + }, + }, + quantityInCart > 0 + ? h( + "span", + { className: "product-qty" }, + quantityInCart, + ) + : null, + h( + "button", + { + type: "button", + className: "store-btn store-btn-primary", + onClick: () => actions.addToCart(item), + }, + "Add to Cart", + ), + ), + ), + ); + }; + + StorefrontApp.componentFns.EmptyStateCard = function EmptyStateCard({ + title, + copy, + actionLabel, + onAction, + }) { + return h( + "article", + { className: "empty-state" }, + h("span", { className: "empty-state-kicker" }, "No Results"), + h("strong", { className: "card-label" }, title), + h("p", { className: "empty-state-copy" }, copy), + actionLabel && typeof onAction === "function" + ? h( + "button", + { + type: "button", + className: "store-btn store-btn-secondary", + onClick: onAction, + }, + actionLabel, + ) + : null, + ); + }; + + StorefrontApp.componentFns.CategoryGrid = function CategoryGrid(children) { + return createGrid("is-categories", children); + }; + + StorefrontApp.componentFns.SubcategoryGrid = function SubcategoryGrid( + children, + ) { + return createGrid("is-subcategories", children); + }; + + 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", + ), + ), + ), + ); + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const store = StorefrontApp.store; + const getters = StorefrontApp.getters; + const actions = StorefrontApp.actions; + const { storeConfig } = StorefrontApp.data; + const scopeAttr = "data-ui-store-cart"; + const scopeSelector = `[${scopeAttr}]`; + const cartCss = ` +${scopeSelector} { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +${scopeSelector}.is-open { + pointer-events: auto; +} + +${scopeSelector} .store-cart { + position: absolute; + top: 0.5rem; + right: 0.5rem; + bottom: 0.5rem; + width: min(24rem, calc(100% - 1rem)); + transform: translateX(calc(100% + 1rem)); + transition: transform 180ms ease; +} + +${scopeSelector}.is-open .store-cart { + transform: translateX(0); +} + +${scopeSelector} .cart-card { + height: 100%; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + border-radius: 1.5rem; + border: 1px solid var(--store-border); + background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%); + box-shadow: + 0 18px 40px rgb(11 27 46 / 0.16), + 0 4px 12px rgb(11 27 46 / 0.08); +} + +${scopeSelector} .cart-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +${scopeSelector} .cart-close { + min-width: 2.1rem; + height: 2.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 0.6rem; + border: 1px solid var(--store-border-strong); + background: rgb(255 255 255 / 0.78); + color: var(--store-accent); + font-size: 0.92rem; + font-weight: 800; + line-height: 1; + box-shadow: 0 6px 16px rgb(18 54 93 / 0.08); +} + +${scopeSelector} .cart-close:hover { + background: var(--store-accent-soft); + border-color: rgb(18 54 93 / 0.24); + color: var(--store-accent); +} + +${scopeSelector} .cart-close:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.25); +} + +${scopeSelector} .cart-status, +${scopeSelector} .cart-kpi-card, +${scopeSelector} .cart-line { + border-radius: 0.95rem; + background: rgb(255 255 255 / 0.58); + border: 1px solid var(--store-border); +} + +${scopeSelector} .cart-status, +${scopeSelector} .cart-kpi-card, +${scopeSelector} .cart-line { + padding: 0.95rem; +} + +${scopeSelector} .cart-kpi { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +${scopeSelector} .kpi-label { + display: block; + margin-bottom: 0.3rem; + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 700; + color: var(--store-text-subtle); +} + +${scopeSelector} .kpi-value { + font-size: 1rem; + font-weight: 700; +} + +${scopeSelector} .cart-lines { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: auto; + scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.55); +} + +${scopeSelector} .cart-lines::-webkit-scrollbar { + width: 12px; +} + +${scopeSelector} .cart-lines::-webkit-scrollbar-track { + background: rgb(255 255 255 / 0.55); + border-radius: 999px; +} + +${scopeSelector} .cart-lines::-webkit-scrollbar-thumb { + background: rgb(120 136 155 / 0.9); + border-radius: 999px; + border: 2px solid rgb(255 255 255 / 0.55); +} + +${scopeSelector} .cart-line { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +${scopeSelector} .cart-line-top, +${scopeSelector} .cart-line-controls, +${scopeSelector} .summary-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .cart-line-title { + font-size: 0.92rem; + font-weight: 700; +} + +${scopeSelector} .cart-line-code { + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--store-text-subtle); +} + +${scopeSelector} .qty-controls { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +${scopeSelector} .qty-badge { + min-width: 1.9rem; + text-align: center; + font-weight: 700; +} + +${scopeSelector} .qty-btn, +${scopeSelector} .remove-btn { + min-width: 2rem; + height: 2rem; + padding: 0 0.65rem; +} + +${scopeSelector} .cart-summary { + padding-top: 0.25rem; + border-top: 1px solid var(--store-accent-line); + display: grid; + gap: 0.7rem; +} + +${scopeSelector} .payment-source-field { + display: grid; + gap: 0.65rem; +} + +${scopeSelector} .payment-source-select { + width: 100%; + min-height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.78); + color: var(--store-text-main); +} + +${scopeSelector} .payment-source-meta, +${scopeSelector} .payment-source-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .payment-source-meta { + padding: 0.85rem 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.44); +} + +${scopeSelector} .payment-source-detail { + margin: 0.2rem 0 0; + font-size: 0.82rem; + line-height: 1.4; + color: var(--store-text-muted); +} + +${scopeSelector} .payment-source-label { + font-weight: 700; + color: var(--store-text-main); +} + +${scopeSelector} .payment-source-balance { + font-weight: 700; + color: var(--store-success); +} + +${scopeSelector} .payment-source-state { + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--store-text-subtle); +} + +${scopeSelector} .summary-row.total { + font-size: 1rem; + font-weight: 700; +} + +${scopeSelector} .summary-label, +${scopeSelector} .cart-line-meta { + color: var(--store-text-muted); +} + +${scopeSelector} .summary-value { + font-weight: 700; +} + +${scopeSelector} .summary-actions { + display: grid; + gap: 0.65rem; +} + +${scopeSelector} .cart-empty { + padding: 1rem; + border-radius: 0.95rem; + border: 1px dashed var(--store-border); + color: var(--store-text-muted); + background: rgb(255 255 255 / 0.38); +} + +@media (max-width: 1120px) { + ${scopeSelector} .store-cart { + top: 0; + right: 0; + bottom: 0; + width: min(24rem, 100%); + } +} +`; + + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + StorefrontApp.componentFns.Cart = function Cart() { + const state = getters.getStoreState(store); + const summary = getters.summarizeCart(state.cartItems); + const paymentSources = getters.getPaymentSources(storeConfig); + const selectedPaymentSource = + getters.getPaymentSourceById( + storeConfig, + state.selectedPaymentSource, + ) || + paymentSources[0] || + null; + const availablePaymentSourceCount = paymentSources.filter( + (source) => source.enabled !== false, + ).length; + const selectedPaymentLabel = selectedPaymentSource + ? selectedPaymentSource.label + : "Unavailable"; + const selectedPaymentBalance = selectedPaymentSource + ? Number(selectedPaymentSource.balance || 0) + : 0; + const remainingSourceBalance = Math.max( + 0, + selectedPaymentBalance - summary.total, + ); + + ensureScopedStyle("storefront-cart", cartCss); + + return h( + "div", + { + className: state.cartOpen ? "is-open" : "", + [scopeAttr]: "", + "aria-hidden": state.cartOpen ? "false" : "true", + }, + h( + "aside", + { className: "store-cart" }, + h( + "section", + { className: "cart-card" }, + h( + "div", + { className: "cart-header" }, + h( + "div", + null, + h("span", { className: "eyebrow" }, "Cart"), + h( + "h2", + { className: "section-title" }, + "Acquisition Queue", + ), + ), + h( + "button", + { + type: "button", + className: "cart-close", + "aria-label": "Close cart", + title: "Close cart", + onClick: () => actions.closeCart(), + }, + "X", + ), + ), + h( + "div", + { className: "cart-kpi" }, + h( + "div", + { className: "cart-kpi-card" }, + h("span", { className: "kpi-label" }, "Items"), + h( + "span", + { className: "kpi-value" }, + summary.lineCount, + ), + ), + h( + "div", + { className: "cart-kpi-card" }, + h("span", { className: "kpi-label" }, "Payment"), + h( + "span", + { className: "kpi-value" }, + selectedPaymentLabel, + ), + ), + ), + h( + "div", + { className: "cart-status" }, + h("span", { className: "eyebrow" }, "Payment Source"), + h( + "div", + { className: "payment-source-field" }, + h( + "select", + { + className: "payment-source-select", + value: state.selectedPaymentSource, + onChange: (event) => + actions.selectPaymentSource( + event.target.value, + ), + }, + paymentSources.map((source) => + h( + "option", + { + value: source.id, + disabled: source.enabled === false, + }, + source.enabled === false + ? `${source.label} (Locked)` + : source.label, + ), + ), + ), + selectedPaymentSource + ? h( + "div", + { + className: "payment-source-meta", + }, + h( + "div", + null, + h( + "div", + { + className: + "payment-source-row", + }, + h( + "span", + { + className: + "payment-source-label", + }, + selectedPaymentSource.label, + ), + h( + "span", + { + className: + "payment-source-balance", + }, + getters.formatCurrency( + selectedPaymentSource.balance, + ), + ), + ), + h( + "p", + { + className: + "payment-source-detail", + }, + selectedPaymentSource.detail, + ), + ), + h( + "span", + { + className: "payment-source-state", + }, + availablePaymentSourceCount > 0 + ? selectedPaymentSource.enabled === + false + ? "Locked" + : "Available" + : "Unavailable", + ), + ) + : null, + ), + ), + h( + "div", + { + className: "cart-lines", + "data-preserve-scroll-id": "cart-lines", + }, + summary.lineCount > 0 + ? state.cartItems.map((item) => + h( + "div", + { className: "cart-line" }, + h( + "div", + { className: "cart-line-top" }, + h( + "div", + null, + h( + "div", + { + className: + "cart-line-code", + }, + item.code, + ), + h( + "div", + { + className: + "cart-line-title", + }, + item.name, + ), + ), + h( + "strong", + null, + getters.formatCurrency( + getters.parsePrice( + item.price, + ) * item.quantity, + ), + ), + ), + h( + "div", + { className: "cart-line-controls" }, + h( + "div", + { className: "qty-controls" }, + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary qty-btn", + onClick: () => + actions.decrementCartItem( + item.code, + ), + }, + "-", + ), + h( + "span", + { className: "qty-badge" }, + item.quantity, + ), + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary qty-btn", + onClick: () => + actions.incrementCartItem( + item.code, + ), + }, + "+", + ), + ), + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary remove-btn", + onClick: () => + actions.removeCartItem( + item.code, + ), + }, + "Remove", + ), + ), + ), + ) + : h( + "div", + { className: "cart-empty" }, + "No items are queued yet. Add products from the catalog to build a checkout payload.", + ), + ), + h( + "div", + { className: "cart-summary" }, + h( + "div", + { className: "summary-row" }, + h("span", { className: "summary-label" }, "Items"), + h( + "span", + { className: "summary-value" }, + summary.itemCount, + ), + ), + h( + "div", + { className: "summary-row" }, + h( + "span", + { className: "summary-label" }, + "Subtotal", + ), + h( + "span", + { className: "summary-value" }, + getters.formatCurrency(summary.subtotal), + ), + ), + h( + "div", + { className: "summary-row" }, + h( + "span", + { className: "summary-label" }, + "Remaining Source", + ), + h( + "span", + { className: "summary-value" }, + getters.formatCurrency(remainingSourceBalance), + ), + ), + h( + "div", + { className: "summary-row total" }, + h("span", { className: "summary-label" }, "Total"), + h( + "span", + { className: "summary-value" }, + getters.formatCurrency(summary.total), + ), + ), + ), + h( + "div", + { className: "summary-actions" }, + h( + "button", + { + type: "button", + className: "store-btn store-btn-primary", + disabled: + summary.lineCount === 0 || + state.isCheckingOut, + onClick: () => actions.requestCheckout(), + }, + state.isCheckingOut + ? "Submitting Request..." + : "Submit Checkout", + ), + ), + ), + ), + ); + }; +})(); + +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const getters = StorefrontApp.getters; + const store = StorefrontApp.store; + const actions = StorefrontApp.actions; + const scopeAttr = "data-ui-store-navbar"; + const scopeSelector = `[${scopeAttr}]`; + const navbarCss = ` +${scopeSelector} { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1rem; + margin-bottom: 0.95rem; + border-bottom: 1px solid var(--store-accent-line); + background: + linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%), + linear-gradient(180deg, rgb(236 241 246 / 0.52) 0%, rgb(245 243 239 / 0.2) 100%); +} + +${scopeSelector} .store-breadcrumbs { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + flex-wrap: wrap; +} + +${scopeSelector} .breadcrumb-link, +${scopeSelector} .breadcrumb-current, +${scopeSelector} .breadcrumb-separator { + font-size: 0.78rem; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 700; +} + +${scopeSelector} .breadcrumb-link { + padding: 0; + border: 0; + background: transparent; + color: var(--store-text-subtle); +} + +${scopeSelector} .breadcrumb-link:hover { + color: var(--store-accent); +} + +${scopeSelector} .breadcrumb-current { + color: var(--store-accent); +} + +${scopeSelector} .breadcrumb-separator { + color: rgb(124 138 155 / 0.72); +} + +${scopeSelector} .store-cart-btn { + position: relative; + width: 2.6rem; + height: 2.6rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border-radius: 0.7rem; + border: 1px solid var(--store-border-strong); + background: rgb(255 255 255 / 0.68); + color: var(--store-accent); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75); +} + +${scopeSelector} .store-cart-btn:hover { + background: rgb(219 231 243 / 0.88); +} + +${scopeSelector} .cart-toggle-icon { + position: relative; + width: 0.95rem; + height: 0.8rem; + border: 1.5px solid currentColor; + border-radius: 0.16rem 0.16rem 0.24rem 0.24rem; +} + +${scopeSelector} .cart-toggle-icon::before { + content: ""; + position: absolute; + top: -0.34rem; + left: 0.2rem; + width: 0.5rem; + height: 0.3rem; + border: 1.5px solid currentColor; + border-bottom: 0; + border-radius: 0.35rem 0.35rem 0 0; +} + +${scopeSelector} .cart-count { + position: absolute; + top: -0.35rem; + right: -0.35rem; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--store-accent); + color: #fff; + font-size: 0.68rem; + font-weight: 700; +} + +@media (max-width: 1120px) { + ${scopeSelector} { + align-items: flex-start; + } +} +`; + + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + StorefrontApp.componentFns.Navbar = function Navbar() { + const state = getters.getStoreState(store); + const items = getters.getStoreBreadcrumbs(state); + const cartSummary = getters.summarizeCart(state.cartItems); + + ensureScopedStyle("storefront-navbar", navbarCss); + + return h( + "nav", + { [scopeAttr]: "" }, + h( + "div", + { + className: "store-breadcrumbs", + "aria-label": "Store navigation", + }, + items.map((item, index) => { + const isCurrent = index === items.length - 1; + + if (isCurrent) { + return h( + "span", + { className: "breadcrumb-current" }, + item.label, + ); + } + + return [ + h( + "button", + { + type: "button", + className: "breadcrumb-link", + onClick: () => + actions.navigateToBreadcrumb(item.id), + }, + item.label, + ), + h("span", { className: "breadcrumb-separator" }, "/"), + ]; + }), + ), + h( + "button", + { + type: "button", + className: "store-cart-btn", + onClick: () => actions.toggleCart(), + title: state.cartOpen ? "Close cart" : "Open cart", + "aria-label": state.cartOpen ? "Close cart" : "Open cart", + }, + h("span", { + className: "cart-toggle-icon", + "aria-hidden": "true", + }), + cartSummary.itemCount > 0 + ? h( + "span", + { className: "cart-count" }, + cartSummary.itemCount, + ) + : null, + ), + ); + }; +})(); + +(function () { + const ForgeWebUI = window.ForgeWebUI; + const StorefrontApp = window.StorefrontApp; + const app = ForgeWebUI.createApp({ + name: "store", + root: "#app", + setup({ root }) { + ForgeWebUI.mount(root, () => StorefrontApp.components.App(), { + preserveScroll: false, + }); + + if (StorefrontApp.bridge) { + StorefrontApp.bridge.notifyReady(); + } + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/store/ui/_site/useStore.js b/arma/client/addons/store/ui/_site/useStore.js deleted file mode 100644 index 23d20f3..0000000 --- a/arma/client/addons/store/ui/_site/useStore.js +++ /dev/null @@ -1,9 +0,0 @@ -(function () { - const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); - const { createSignal } = StorefrontApp.runtime; - const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); - - StorefrontApp.store = SharedLogic.createStorefrontStore({ - createSignal, - }); -})(); diff --git a/arma/client/addons/store/ui/src/bootstrap.js b/arma/client/addons/store/ui/src/bootstrap.js new file mode 100644 index 0000000..fe6082f --- /dev/null +++ b/arma/client/addons/store/ui/src/bootstrap.js @@ -0,0 +1,19 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const StorefrontApp = window.StorefrontApp; + const app = ForgeWebUI.createApp({ + name: "store", + root: "#app", + setup({ root }) { + ForgeWebUI.mount(root, () => StorefrontApp.components.App(), { + preserveScroll: false, + }); + + if (StorefrontApp.bridge) { + StorefrontApp.bridge.notifyReady(); + } + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/store/ui/src/bridge.js b/arma/client/addons/store/ui/src/bridge.js new file mode 100644 index 0000000..a38aa92 --- /dev/null +++ b/arma/client/addons/store/ui/src/bridge.js @@ -0,0 +1,81 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const store = StorefrontApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "store::close", + globalName: "StoreUIBridge", + readyEvent: "store::ready", + }); + + function requestClose() { + return bridge.close({}); + } + + function requestCheckout(payload) { + return bridge.send("store::checkout::request", payload); + } + + function requestCategory(payload) { + return bridge.send("store::category::request", payload); + } + + function notifyReady() { + return bridge.ready({ loaded: true }); + } + + bridge.on("store::hydrate", (payloadData) => { + StorefrontApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + }); + + bridge.on("store::config::hydrate", (payloadData) => { + StorefrontApp.data.applyHydratePayload(payloadData); + store.hydrateStoreConfig(payloadData); + }); + + bridge.on("store::checkout::success", (payloadData) => { + store.setIsCheckingOut(false); + store.setCartItems([]); + store.setCartOpen(false); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "success", + payloadData.message || "Checkout completed.", + ); + } + }); + + bridge.on("store::category::hydrate", (payloadData) => { + store.hydrateCategoryItems(payloadData); + }); + + bridge.on("store::category::failure", (payloadData) => { + store.finishCategoryRequest(payloadData.category || ""); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "error", + payloadData.message || "Category request failed.", + ); + } + }); + + bridge.on("store::checkout::failure", (payloadData) => { + store.setIsCheckingOut(false); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "error", + payloadData.message || "Checkout failed.", + ); + } + }); + + StorefrontApp.bridge = { + close: bridge.close, + requestClose, + requestCheckout, + requestCategory, + notifyReady, + receive: bridge.receive, + sendEvent: bridge.send, + }; +})(); diff --git a/arma/client/addons/store/ui/_site/components/AppShell.js b/arma/client/addons/store/ui/src/components/AppShell.js similarity index 79% rename from arma/client/addons/store/ui/_site/components/AppShell.js rename to arma/client/addons/store/ui/src/components/AppShell.js index 70c2f27..f33d28a 100644 --- a/arma/client/addons/store/ui/_site/components/AppShell.js +++ b/arma/client/addons/store/ui/src/components/AppShell.js @@ -1,6 +1,7 @@ (function () { const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); const { h, ensureScopedStyle } = StorefrontApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; const store = StorefrontApp.store; const getters = StorefrontApp.getters; const actions = StorefrontApp.actions; @@ -17,26 +18,6 @@ ${scopeSelector} { background: var(--store-shell-bg); } -${scopeSelector} .window-titlebar { - position: relative; - z-index: 5; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.9rem 1rem 0.95rem 1.2rem; - background: var(--store-titlebar-bg); - color: #f4f8fd; - border-bottom: 1px solid var(--store-titlebar-border); -} - -${scopeSelector} .window-titlebar-brand { - display: flex; - flex-direction: column; - gap: 0.1rem; -} - -${scopeSelector} .window-titlebar-kicker, ${scopeSelector} .footer-title, ${scopeSelector} .eyebrow { font-size: 0.68rem; @@ -46,17 +27,6 @@ ${scopeSelector} .eyebrow { font-weight: 700; } -${scopeSelector} .window-titlebar-kicker { - color: rgb(214 227 241 / 0.72); -} - -${scopeSelector} .window-titlebar-title { - font-size: 1.12rem; - font-weight: 700; - letter-spacing: -0.03em; -} - -${scopeSelector} .window-titlebar-controls, ${scopeSelector} .module-header, ${scopeSelector} .store-panel-header { display: flex; @@ -65,30 +35,6 @@ ${scopeSelector} .store-panel-header { gap: 1rem; } -${scopeSelector} .window-control-btn { - min-width: 2rem; - height: 2rem; - padding: 0 0.7rem; - border-radius: 0.45rem; - border: 1px solid rgb(197 220 243 / 0.16); - background: rgb(255 255 255 / 0.04); - color: rgb(237 244 251 / 0.88); -} - -${scopeSelector} .window-control-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -${scopeSelector} .window-control-btn.is-close { - background: rgb(255 255 255 / 0.1); -} - -${scopeSelector} .window-control-btn.is-close:hover { - background: rgb(185 67 67 / 0.9); - border-color: rgb(255 222 222 / 0.45); -} - ${scopeSelector} .store-app { flex: 1; min-height: 0; @@ -206,12 +152,6 @@ ${scopeSelector} .quick-tag { text-transform: uppercase; } -${scopeSelector} .quick-tag.is-active { - background: var(--store-accent-soft); - color: var(--store-accent); - border-color: rgb(18 54 93 / 0.2); -} - ${scopeSelector} .filter-stack { display: grid; gap: 0.85rem; @@ -336,64 +276,6 @@ ${scopeSelector} .store-toast.is-error { StorefrontApp.components = StorefrontApp.components || {}; StorefrontApp.componentFns = StorefrontApp.componentFns || {}; - function WindowTitleBar() { - return h( - "div", - { className: "window-titlebar" }, - h( - "div", - { className: "window-titlebar-brand" }, - h( - "span", - { className: "window-titlebar-kicker" }, - "FORGE Logistics", - ), - h( - "span", - { className: "window-titlebar-title" }, - "Supply Exchange", - ), - ), - h( - "div", - { className: "window-titlebar-controls" }, - h( - "button", - { - type: "button", - className: "window-control-btn", - disabled: true, - title: "Minimize unavailable", - "aria-label": "Minimize unavailable", - }, - "-", - ), - h( - "button", - { - type: "button", - className: "window-control-btn", - disabled: true, - title: "Maximize unavailable", - "aria-label": "Maximize unavailable", - }, - "[ ]", - ), - h( - "button", - { - type: "button", - className: "window-control-btn is-close", - title: "Close", - "aria-label": "Close store interface", - onClick: () => actions.closeStore(), - }, - "X", - ), - ), - ); - } - function renderStoreBody(state) { const { CategoryCard, @@ -485,6 +367,10 @@ ${scopeSelector} .store-toast.is-error { const header = getters.getStoreHeader(state); const notice = store.getNotice(); const activeQuery = state.searchQuery; + const paymentSources = getters.getPaymentSources(storeConfig); + const availablePaymentSourceCount = paymentSources.filter( + (source) => source.enabled !== false, + ).length; const filterDepartment = state.view === "items" ? actions.formatTitle( @@ -502,7 +388,12 @@ ${scopeSelector} .store-toast.is-error { return h( "div", { [scopeAttr]: "" }, - WindowTitleBar(), + WindowTitleBar({ + kicker: "FORGE Logistics", + title: "Supply Exchange", + onClose: () => actions.closeStore(), + closeLabel: "Close store interface", + }), notice.text ? h( "div", @@ -593,19 +484,7 @@ ${scopeSelector} .store-toast.is-error { "div", { className: "quick-tags" }, (storeConfig.searchTags || []).map((tag) => - h( - "button", - { - type: "button", - className: - activeQuery === tag - ? "quick-tag is-active" - : "quick-tag", - onClick: () => - actions.applySearchQuery(tag), - }, - tag, - ), + h("span", { className: "quick-tag" }, tag), ), ), ), @@ -645,7 +524,6 @@ ${scopeSelector} .store-toast.is-error { h( "div", { className: "filter-value" }, - h("span", null, "Operational Tier"), h( "span", { className: "filter-placeholder" }, @@ -664,7 +542,6 @@ ${scopeSelector} .store-toast.is-error { h( "div", { className: "filter-value" }, - h("span", null, "Stock Window"), h( "span", { className: "filter-placeholder" }, @@ -672,26 +549,6 @@ ${scopeSelector} .store-toast.is-error { ), ), ), - h( - "div", - { className: "filter-group" }, - h( - "span", - { className: "filter-label" }, - "Approval", - ), - h( - "div", - { className: "filter-value" }, - h("span", null, "Purchase Level"), - h( - "span", - { className: "filter-placeholder" }, - session.approvalRole || - storeConfig.approval, - ), - ), - ), h( "div", { className: "filter-group" }, @@ -703,7 +560,6 @@ ${scopeSelector} .store-toast.is-error { h( "div", { className: "filter-value" }, - h("span", null, "Checkout Source"), h( "span", { className: "filter-placeholder" }, @@ -782,13 +638,11 @@ ${scopeSelector} .store-toast.is-error { h( "div", { className: "footer-block" }, - h("span", { className: "footer-title" }, "Bridge State"), + h("span", { className: "footer-title" }, "Purchase Access"), h( "span", { className: "footer-copy" }, - session.actorName - ? `Hydrated for ${session.actorName}. Checkout remains a stub until the procurement backend is wired.` - : "The browser bridge is active. Hydration and checkout events now flow through the same contract shape as the org UI.", + `${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`, ), ), ), diff --git a/arma/client/addons/store/ui/_site/components/cards.js b/arma/client/addons/store/ui/src/components/cards.js similarity index 100% rename from arma/client/addons/store/ui/_site/components/cards.js rename to arma/client/addons/store/ui/src/components/cards.js diff --git a/arma/client/addons/store/ui/_site/components/cart.js b/arma/client/addons/store/ui/src/components/cart.js similarity index 92% rename from arma/client/addons/store/ui/_site/components/cart.js rename to arma/client/addons/store/ui/src/components/cart.js index 57be6ad..9678424 100644 --- a/arma/client/addons/store/ui/_site/components/cart.js +++ b/arma/client/addons/store/ui/src/components/cart.js @@ -62,32 +62,23 @@ ${scopeSelector} .cart-close { justify-content: center; padding: 0; border-radius: 0.6rem; - border: 1px solid rgb(173 48 48 / 0.9); - background: linear-gradient( - 180deg, - rgb(214 92 92) 0%, - rgb(175 52 52) 100% - ); - color: #fff; + border: 1px solid var(--store-border-strong); + background: rgb(255 255 255 / 0.78); + color: var(--store-accent); font-size: 0.92rem; font-weight: 800; line-height: 1; - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 0.26), - 0 8px 18px rgb(138 61 61 / 0.28); + box-shadow: 0 6px 16px rgb(18 54 93 / 0.08); } ${scopeSelector} .cart-close:hover { - background: linear-gradient( - 180deg, - rgb(226 107 107) 0%, - rgb(187 61 61) 100% - ); - border-color: rgb(173 48 48); + background: var(--store-accent-soft); + border-color: rgb(18 54 93 / 0.24); + color: var(--store-accent); } ${scopeSelector} .cart-close:focus-visible { - outline: 2px solid rgb(191 80 80 / 0.35); + outline: 2px solid rgb(18 54 93 / 0.25); } ${scopeSelector} .cart-status, @@ -307,7 +298,9 @@ ${scopeSelector} .cart-empty { getters.getPaymentSourceById( storeConfig, state.selectedPaymentSource, - ) || paymentSources[0] || null; + ) || + paymentSources[0] || + null; const availablePaymentSourceCount = paymentSources.filter( (source) => source.enabled !== false, ).length; @@ -378,11 +371,7 @@ ${scopeSelector} .cart-empty { h( "div", { className: "cart-kpi-card" }, - h( - "span", - { className: "kpi-label" }, - "Payment", - ), + h("span", { className: "kpi-label" }, "Payment"), h( "span", { className: "kpi-value" }, @@ -393,11 +382,7 @@ ${scopeSelector} .cart-empty { h( "div", { className: "cart-status" }, - h( - "span", - { className: "eyebrow" }, - "Payment Source", - ), + h("span", { className: "eyebrow" }, "Payment Source"), h( "div", { className: "payment-source-field" }, @@ -416,8 +401,7 @@ ${scopeSelector} .cart-empty { "option", { value: source.id, - disabled: - source.enabled === false, + disabled: source.enabled === false, }, source.enabled === false ? `${source.label} (Locked)` @@ -427,10 +411,9 @@ ${scopeSelector} .cart-empty { ), selectedPaymentSource ? h( - "div", - { - className: - "payment-source-meta", + "div", + { + className: "payment-source-meta", }, h( "div", @@ -472,8 +455,7 @@ ${scopeSelector} .cart-empty { h( "span", { - className: - "payment-source-state", + className: "payment-source-state", }, availablePaymentSourceCount > 0 ? selectedPaymentSource.enabled === @@ -628,9 +610,7 @@ ${scopeSelector} .cart-empty { h( "span", { className: "summary-value" }, - getters.formatCurrency( - remainingSourceBalance, - ), + getters.formatCurrency(remainingSourceBalance), ), ), h( @@ -647,15 +627,6 @@ ${scopeSelector} .cart-empty { h( "div", { className: "summary-actions" }, - h( - "button", - { - type: "button", - className: "store-btn store-btn-secondary", - onClick: () => actions.closeCart(), - }, - "Review Later", - ), h( "button", { diff --git a/arma/client/addons/store/ui/_site/components/navbar.js b/arma/client/addons/store/ui/src/components/navbar.js similarity index 100% rename from arma/client/addons/store/ui/_site/components/navbar.js rename to arma/client/addons/store/ui/src/components/navbar.js diff --git a/arma/client/addons/store/ui/src/data.js b/arma/client/addons/store/ui/src/data.js new file mode 100644 index 0000000..eced997 --- /dev/null +++ b/arma/client/addons/store/ui/src/data.js @@ -0,0 +1,134 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + + const defaultSession = { + actorName: "", + actorUid: "", + approval: "Field Access", + orgId: "", + orgName: "", + orgLeader: false, + defaultOrgCeo: false, + canUseOrgFunds: false, + }; + + const defaultStoreConfig = { + budget: 50000, + creditLine: 0, + availability: "In-Stock", + moduleState: "Preview", + searchTags: [ + "Attachment", + "Grenade", + "Medical", + "Consumable", + "Static", + "Scope", + "Item", + "Misc", + ], + paymentSources: [ + { + id: "cash", + label: "Cash", + balance: 0, + enabled: false, + detail: "Use on-hand cash carried by the player.", + }, + { + id: "bank", + label: "Bank", + balance: 0, + enabled: false, + detail: "Charge the player bank account.", + }, + { + id: "org_funds", + label: "Org Funds", + balance: 0, + enabled: false, + detail: "Only organization leaders or the default-org CEO can use treasury funds.", + }, + { + id: "credit_line", + label: "Credit Line", + balance: 0, + enabled: false, + detail: "No approved credit line is assigned to this member.", + }, + ], + defaultPaymentSource: "cash", + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + const catalog = { + categoryCards: [ + { id: "uniforms", label: "Uniforms" }, + { id: "headgear", label: "Headgear" }, + { id: "facewear", label: "Facewear" }, + { id: "vests", label: "Vests" }, + { id: "weapons", label: "Weapons" }, + { id: "ammo", label: "Ammo" }, + { id: "items", label: "Items" }, + { id: "vehicles", label: "Vehicles" }, + ], + vehicleCards: [ + { id: "cars", label: "Cars" }, + { id: "armor", label: "Armor" }, + { id: "helis", label: "Helicopters" }, + { id: "planes", label: "Planes" }, + { id: "naval", label: "Naval" }, + { id: "other", label: "Other" }, + ], + weaponCards: [ + { id: "primary", label: "Primary" }, + { id: "secondary", label: "Secondary" }, + { id: "handgun", label: "Handgun" }, + ], + previewItems: { + uniforms: [], + headgear: [], + facewear: [], + vests: [], + ammo: [], + items: [], + primary: [], + secondary: [], + handgun: [], + cars: [], + armor: [], + helis: [], + planes: [], + naval: [], + other: [], + }, + }; + + StorefrontApp.data = { + catalog, + session: Object.assign({}, defaultSession), + storeConfig: Object.assign({}, defaultStoreConfig), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.storeConfig, + Object.assign( + {}, + defaultStoreConfig, + payload?.storeConfig || {}, + ), + ); + }, + }; +})(); diff --git a/arma/client/addons/store/ui/_site/media.js b/arma/client/addons/store/ui/src/media.js similarity index 97% rename from arma/client/addons/store/ui/_site/media.js rename to arma/client/addons/store/ui/src/media.js index 48917db..31f36fb 100644 --- a/arma/client/addons/store/ui/_site/media.js +++ b/arma/client/addons/store/ui/src/media.js @@ -1,6 +1,7 @@ (function () { const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); const runtime = StorefrontApp.runtime; + const [getTextureVersion, setTextureVersion] = runtime.createSignal(0); const MAX_CONCURRENT_TEXTURES = 6; const RERENDER_DELAY_MS = 48; const textureCache = Object.create(null); @@ -59,9 +60,7 @@ rerenderTimer = window.setTimeout(() => { rerenderTimer = 0; - if (runtime && typeof runtime.rerender === "function") { - runtime.rerender(); - } + setTextureVersion((currentVersion) => currentVersion + 1); }, RERENDER_DELAY_MS); } @@ -221,6 +220,7 @@ } function getTextureState(path) { + getTextureVersion(); const normalizedPath = normalizeTexturePath(path); return { path: normalizedPath, @@ -236,6 +236,7 @@ } function getTextureSource(path) { + getTextureVersion(); const normalizedPath = normalizeTexturePath(path); if (!normalizedPath) { return ""; diff --git a/arma/client/addons/store/ui/_site/pages/StoreView.js b/arma/client/addons/store/ui/src/pages/StoreView.js similarity index 100% rename from arma/client/addons/store/ui/_site/pages/StoreView.js rename to arma/client/addons/store/ui/src/pages/StoreView.js diff --git a/arma/client/addons/store/ui/_site/logic/events.js b/arma/client/addons/store/ui/src/registry/events.js similarity index 100% rename from arma/client/addons/store/ui/_site/logic/events.js rename to arma/client/addons/store/ui/src/registry/events.js diff --git a/arma/client/addons/store/ui/_site/logic/store.js b/arma/client/addons/store/ui/src/registry/store.js similarity index 92% rename from arma/client/addons/store/ui/_site/logic/store.js rename to arma/client/addons/store/ui/src/registry/store.js index f6c0c49..e8b608e 100644 --- a/arma/client/addons/store/ui/_site/logic/store.js +++ b/arma/client/addons/store/ui/src/registry/store.js @@ -1,4 +1,6 @@ (function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { createSignal } = StorefrontApp.runtime; const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); SharedLogic.createStorefrontStore = function createStorefrontStore({ @@ -180,15 +182,17 @@ this.finishCategoryRequest(categoryKey); } - ensureSelectedPaymentSource(workspace) { - const paymentSources = Array.isArray(workspace?.paymentSources) - ? workspace.paymentSources + ensureSelectedPaymentSource(storeConfig) { + const paymentSources = Array.isArray( + storeConfig?.paymentSources, + ) + ? storeConfig.paymentSources : []; const currentSource = String( this.getSelectedPaymentSource() || "", ).trim(); const defaultSource = String( - workspace?.defaultPaymentSource || "", + storeConfig?.defaultPaymentSource || "", ).trim(); const sourceIds = paymentSources.map((source) => String(source?.id || "").trim(), @@ -253,21 +257,17 @@ ? payload.cartItems : []; - this.setCartItems( - cartItems.map(normalizeCartItem), - ); + this.setCartItems(cartItems.map(normalizeCartItem)); this.setCartOpen(false); this.setIsCheckingOut(false); this.setCatalogItemsByKey({}); this.setCatalogRequestKey(""); this.setIsCatalogLoading(false); this.setCatalogPage(1); - this.ensureSelectedPaymentSource( - payload?.workspace || payload?.storeConfig || {}, - ); + this.ensureSelectedPaymentSource(payload?.storeConfig || {}); } - hydrateWorkspace(payload) { + hydrateStoreConfig(payload) { const cartItems = Array.isArray(payload?.cartItems) ? payload.cartItems : []; @@ -275,12 +275,14 @@ this.setCartItems(cartItems.map(normalizeCartItem)); this.setCartOpen(false); this.setIsCheckingOut(false); - this.ensureSelectedPaymentSource( - payload?.workspace || payload?.storeConfig || {}, - ); + this.ensureSelectedPaymentSource(payload?.storeConfig || {}); } } return new StorefrontStore(); }; + + StorefrontApp.store = SharedLogic.createStorefrontStore({ + createSignal, + }); })(); diff --git a/arma/client/addons/store/ui/src/runtime.js b/arma/client/addons/store/ui/src/runtime.js new file mode 100644 index 0000000..2462fdd --- /dev/null +++ b/arma/client/addons/store/ui/src/runtime.js @@ -0,0 +1,6 @@ +(function () { + const runtime = window.ForgeWebUI; + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + StorefrontApp.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/store/ui/src/styles.css b/arma/client/addons/store/ui/src/styles.css new file mode 100644 index 0000000..9bda616 --- /dev/null +++ b/arma/client/addons/store/ui/src/styles.css @@ -0,0 +1,89 @@ +:root { + --store-shell-bg: #e4e3df; + --store-surface: #f5f3ef; + --store-surface-alt: #ece8e2; + --store-surface-strong: #ffffff; + --store-border: rgba(74, 91, 110, 0.2); + --store-border-strong: rgba(20, 46, 79, 0.2); + --store-text-main: #1f2d3d; + --store-text-muted: #6a7787; + --store-text-subtle: #8792a0; + --store-accent: #12365d; + --store-accent-soft: #dbe7f3; + --store-accent-line: rgba(18, 54, 93, 0.12); + --store-success: #2f7d5b; + --store-danger: #8a3d3d; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + color: var(--store-text-main); + background: var(--store-shell-bg); +} + +button, +input, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.35); + outline-offset: 2px; +} + +#app { + width: 100%; + height: 100%; +} + +.store-btn { + min-height: 2.75rem; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border-strong); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.store-btn.store-btn-primary { + background: rgb(255 255 255 / 0.68); + color: var(--store-accent); +} + +.store-btn.store-btn-primary:hover { + background: rgb(219 231 243 / 0.88); +} + +.store-btn.store-btn-secondary { + background: rgb(255 255 255 / 0.42); + color: var(--store-text-muted); +} + +.store-btn.store-btn-secondary:hover { + background: rgb(255 255 255 / 0.6); + color: var(--store-text-main); +} diff --git a/arma/client/addons/store/ui/ui.config.mjs b/arma/client/addons/store/ui/ui.config.mjs new file mode 100644 index 0000000..b99ac7b --- /dev/null +++ b/arma/client/addons/store/ui/ui.config.mjs @@ -0,0 +1,38 @@ +export default { + addonName: "store", + title: "FORGE Supply Exchange", + logLabel: "Store UI", + outputDir: "_site", + jsBundles: [ + { + name: "Store UI app", + output: "store-ui.js", + sources: [ + "src/runtime.js", + "src/media.js", + "src/data.js", + "src/registry/store.js", + "src/pages/StoreView.js", + "src/bridge.js", + "src/registry/events.js", + "src/components/AppShell.js", + "src/components/cards.js", + "src/components/cart.js", + "src/components/navbar.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Store UI styles", + output: "store-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["store-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["store-ui.js"], + }, +}; diff --git a/build-arma.ps1 b/build-arma.ps1 index fe6a29f..d7b67c3 100644 --- a/build-arma.ps1 +++ b/build-arma.ps1 @@ -28,6 +28,22 @@ param( $ErrorActionPreference = "Stop" $scriptDir = $PSScriptRoot +function Build-WebUIAssets { + Write-Host "`n=== Building Web UI Bundles ===" -ForegroundColor Cyan + + Push-Location $scriptDir + try { + & npm run build:webui + if ($LASTEXITCODE -ne 0) { + throw "Web UI bundle build failed with exit code $LASTEXITCODE" + } + Write-Host "✓ Web UI bundles built successfully" -ForegroundColor Green + } + finally { + Pop-Location + } +} + function Build-HemttProject { param( [string]$ProjectPath, @@ -54,6 +70,7 @@ $serverPath = Join-Path $scriptDir "arma\server" try { if ($Target -eq 'client' -or $Target -eq 'both') { + Build-WebUIAssets Build-HemttProject -ProjectPath $clientPath -ProjectName "Client" } diff --git a/package.json b/package.json new file mode 100644 index 0000000..bc0565f --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "forge-webui", + "private": true, + "scripts": { + "build:webui": "node tools/build-webui.mjs" + } +} diff --git a/tools/build-webui.mjs b/tools/build-webui.mjs new file mode 100644 index 0000000..fd43b08 --- /dev/null +++ b/tools/build-webui.mjs @@ -0,0 +1,335 @@ +import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, ".."); +const commonUiSrcDir = "arma/client/addons/common/ui/src"; +const commonUiSiteDir = "arma/client/addons/common/ui/_site"; +const clientAddonsDir = path.join(rootDir, "arma/client/addons"); + +function toRepoRelative(absolutePath) { + return path.relative(rootDir, absolutePath).replace(/\\/g, "/"); +} + +function resolveFromRoot(...segments) { + return toRepoRelative(path.join(rootDir, ...segments)); +} + +function resolveFromConfigDir(configDir, relativePath) { + return toRepoRelative(path.resolve(configDir, relativePath)); +} + +const commonJsBundles = [ + { + name: "Forge Web UI runtime", + output: resolveFromRoot(commonUiSiteDir, "forge-webui.js"), + sources: [ + "runtime.js", + "host.js", + "bridge.js", + "app.js", + "windowTitleBar.js", + "index.js", + ].map((relativePath) => resolveFromRoot(commonUiSrcDir, relativePath)), + }, + { + name: "Forge Web UI site loader", + output: resolveFromRoot(commonUiSiteDir, "forge-site-loader.js"), + sources: [resolveFromRoot(commonUiSrcDir, "siteLoader.js")], + }, +]; +const commonFormatSourceTargets = [resolveFromRoot(commonUiSrcDir)]; + +function unique(values) { + return Array.from(new Set(values)); +} + +async function readSource(relativePath) { + const absolutePath = path.join(rootDir, relativePath); + return readFile(absolutePath, "utf8"); +} + +async function writeBundle(outputRelativePath, content) { + const outputPath = path.join(rootDir, outputRelativePath); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, content, "utf8"); +} + +async function cleanOutputDirs(outputDirs) { + const uniqueDirs = unique(outputDirs).filter(Boolean); + + await Promise.all( + uniqueDirs.map(async (relativeDir) => { + const absoluteDir = path.join(rootDir, relativeDir); + await rm(absoluteDir, { force: true, recursive: true }); + await mkdir(absoluteDir, { recursive: true }); + }), + ); +} + +async function buildJsBundle({ name, output, sources }) { + const banner = `/* Generated by tools/build-webui.mjs for ${name}. Do not edit directly. */\n`; + const chunks = await Promise.all(sources.map(readSource)); + await writeBundle(output, banner + chunks.join("\n\n")); + console.log(`Built ${output}`); +} + +async function buildCssBundle({ name, output, sources }) { + const banner = `/* Generated by tools/build-webui.mjs for ${name}. Do not edit directly. */\n`; + const chunks = await Promise.all(sources.map(readSource)); + await writeBundle(output, banner + chunks.join("\n\n")); + console.log(`Built ${output}`); +} + +function renderSiteIndex({ title, siteConfig }) { + const configJson = JSON.stringify(siteConfig, null, 16) + .replace(/^/gm, " ".repeat(12)) + .trimStart(); + + return ` + + + + + ${title} + + + + +
+ + +`; +} + +async function buildHtmlPage({ name, output, title, siteConfig }) { + const banner = `\n`; + await writeBundle(output, banner + renderSiteIndex({ title, siteConfig })); + console.log(`Built ${output}`); +} + +async function pathExists(absolutePath) { + try { + await stat(absolutePath); + return true; + } catch { + return false; + } +} + +async function runPrettier(targets) { + const uniqueTargets = unique(targets).filter(Boolean); + if (uniqueTargets.length === 0) { + return; + } + + console.log(`Formatting ${uniqueTargets.length} Web UI target(s) with Prettier`); + + await new Promise((resolve, reject) => { + const quotedTargets = uniqueTargets.map((target) => + `"${String(target).replace(/"/g, '\\"')}"`, + ); + const command = `npx prettier --write --ignore-unknown ${quotedTargets.join(" ")}`; + const child = spawn(command, [], { + cwd: rootDir, + stdio: "inherit", + shell: true, + }); + + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + + reject( + new Error(`Prettier failed with exit code ${code ?? "unknown"}.`), + ); + }); + }); +} + +async function discoverUiConfigs() { + const addons = await readdir(clientAddonsDir, { withFileTypes: true }); + const configPaths = []; + + for (const entry of addons) { + if (!entry.isDirectory()) { + continue; + } + + const configPath = path.join( + clientAddonsDir, + entry.name, + "ui", + "ui.config.mjs", + ); + + try { + const configStat = await stat(configPath); + if (configStat.isFile()) { + configPaths.push(configPath); + } + } catch { + // UI config is optional per addon. + } + } + + configPaths.sort((left, right) => left.localeCompare(right)); + return configPaths; +} + +async function loadUiConfig(absoluteConfigPath) { + const configModule = await import(pathToFileURL(absoluteConfigPath).href); + const config = configModule.default; + + if (!config || !config.addonName || !config.outputDir || !config.site) { + throw new Error( + `Invalid UI config at ${toRepoRelative(absoluteConfigPath)}.`, + ); + } + + const configDir = path.dirname(absoluteConfigPath); + const configRelativePath = toRepoRelative(absoluteConfigPath); + const outputDir = resolveFromConfigDir(configDir, config.outputDir); + const srcDirPath = path.join(configDir, "src"); + const formatSourceTargets = [configRelativePath]; + + if (await pathExists(srcDirPath)) { + formatSourceTargets.push(toRepoRelative(srcDirPath)); + } + + const jsBundles = (config.jsBundles || []).map((bundle) => ({ + name: bundle.name, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)), + sources: (bundle.sources || []).map((source) => + resolveFromConfigDir(configDir, source), + ), + })); + const cssBundles = (config.cssBundles || []).map((bundle) => ({ + name: bundle.name, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)), + sources: (bundle.sources || []).map((source) => + resolveFromConfigDir(configDir, source), + ), + })); + const htmlPage = { + name: `${config.addonName} UI index`, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, "index.html")), + title: config.title, + siteConfig: { + addonName: config.addonName, + logLabel: config.logLabel || `${config.addonName} UI`, + ...config.site, + }, + }; + + return { + outputDir, + jsBundles, + cssBundles, + htmlPage, + formatSourceTargets, + formatGeneratedTargets: [ + ...jsBundles.map((bundle) => bundle.output), + ...cssBundles.map((bundle) => bundle.output), + htmlPage.output, + ], + }; +} + +async function collectUiBuildArtifacts() { + const configPaths = await discoverUiConfigs(); + const uiConfigs = await Promise.all(configPaths.map(loadUiConfig)); + + return { + outputDirs: uiConfigs.map((config) => config.outputDir), + jsBundles: uiConfigs.flatMap((config) => config.jsBundles), + cssBundles: uiConfigs.flatMap((config) => config.cssBundles), + htmlPages: uiConfigs.map((config) => config.htmlPage), + formatSourceTargets: uiConfigs.flatMap( + (config) => config.formatSourceTargets, + ), + formatGeneratedTargets: uiConfigs.flatMap( + (config) => config.formatGeneratedTargets, + ), + }; +} + +async function build() { + const uiArtifacts = await collectUiBuildArtifacts(); + const commonGeneratedTargets = commonJsBundles.map((bundle) => bundle.output); + const commonOutputDirs = [resolveFromRoot(commonUiSiteDir)]; + + await runPrettier([ + ...commonFormatSourceTargets, + ...uiArtifacts.formatSourceTargets, + ]); + + await cleanOutputDirs([...commonOutputDirs, ...uiArtifacts.outputDirs]); + + await Promise.all([ + ...commonJsBundles.map(buildJsBundle), + ...uiArtifacts.jsBundles.map(buildJsBundle), + ]); + await Promise.all(uiArtifacts.cssBundles.map(buildCssBundle)); + await Promise.all(uiArtifacts.htmlPages.map(buildHtmlPage)); + + await runPrettier([ + ...commonGeneratedTargets, + ...uiArtifacts.formatGeneratedTargets, + ]); +} + +build().catch((error) => { + console.error("Failed to build Forge Web UI bundles."); + console.error(error); + process.exitCode = 1; +});