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