Introduce shared web UI runtime and migrate org/store bridges

- add common ForgeWebUI runtime, site loader, and SQF WebUI bridge base declarations
- migrate org and store web UIs to src-driven bundles and new bridge/bootstrap flow
- update addon configs/prep hooks and document the shared CT_WEBBROWSER framework
This commit is contained in:
Jacob Schmidt 2026-03-14 00:40:34 -05:00
parent 7a214d835d
commit e15d4b3066
95 changed files with 13751 additions and 3065 deletions

View File

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

View File

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

View File

@ -1 +1,2 @@
PREP(initWebUIBridge);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
(function (global) {
const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {});
ForgeWebUI.version = "0.1.0";
})(window);

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ class CfgPatches {
name = COMPONENT_NAME;
requiredVersion = REQUIRED_VERSION;
requiredAddons[] = {
"forge_client_common",
"forge_client_main"
};
units[] = {};

View File

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

View File

@ -1,6 +0,0 @@
/**
* Registry app bootstrap
*/
const root = document.getElementById("app");
window.RegistryApp.runtime.render(window.RegistryApp.components.App, root);

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
<!-- Generated by tools/build-webui.mjs for org UI index. Do not edit directly. -->
<!doctype html>
<html lang="en">
<head>
@ -5,88 +6,55 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ORBIS - Global Organization Network</title>
<script>
const addonRoot = "forge\\forge_client\\addons\\org\\ui\\_site\\";
const styleFiles = ["base.css", "controls.css", "hero.css"];
const scriptFiles = [
"runtime.js",
"logic\\registryStore.js",
"logic\\portalStore.js",
"logic\\portalGetters.js",
"logic\\portalActions.js",
"useRegistryStore.js",
"bridge.js",
"portal\\data.js",
"portal\\useStore.js",
"portal\\getters.js",
"portal\\actions.js",
"components\\navbar.js",
"components\\header.js",
"components\\hero.js",
"components\\footer.js",
"components\\modal.js",
"components\\panelCard.js",
"components\\portal\\metricCard.js",
"components\\portal\\simpleStat.js",
"components\\portal\\overviewCard.js",
"components\\portal\\fleetCard.js",
"components\\portal\\treasuryCard.js",
"components\\portal\\assetsCard.js",
"components\\portal\\membersCard.js",
"components\\portal\\activityCard.js",
"components\\portal\\futureCard.js",
"components\\portal\\dangerCard.js",
"components\\portal\\modalLayer.js",
"views\\DisbandedView.js",
"views\\PortalView.js",
"views\\RegistrationView.js",
"views\\HomeView.js",
"components\\AppShell.js",
"bootstrap.js",
];
window.ForgeSiteConfig = {
addonName: "org",
logLabel: "Org UI",
styles: ["org-ui.css"],
commonScripts: ["forge-webui.js"],
scripts: ["org-ui.js"],
};
function requestText(path) {
if (
typeof A3API !== "undefined" &&
typeof A3API.RequestFile === "function"
) {
return A3API.RequestFile(addonRoot + path);
(function loadForgeSiteLoader() {
const armaLoaderPath =
"forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js";
const browserLoaderPath =
"../../../common/ui/_site/forge-site-loader.js";
function appendScript(js) {
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
}
return fetch(path).then((response) => {
if (!response.ok) {
throw new Error("Failed to load " + path);
function requestLoader() {
if (
typeof A3API !== "undefined" &&
A3API &&
typeof A3API.RequestFile === "function"
) {
return A3API.RequestFile(armaLoaderPath);
}
return response.text();
});
}
return fetch(browserLoaderPath).then((response) => {
if (!response.ok) {
throw new Error(
"Failed to load " + browserLoaderPath,
);
}
function appendStyle(css) {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
return response.text();
});
}
function appendScript(js) {
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
}
Promise.all(styleFiles.map(requestText))
.then((styles) => {
styles.forEach(appendStyle);
return Promise.all(scriptFiles.map(requestText));
})
.then((scripts) => {
scripts.forEach(appendScript);
})
.catch((error) => {
console.error(
"[Org UI] Failed to load site assets.",
error,
);
});
requestLoader()
.then(appendScript)
.catch((error) => {
console.error(
"[Org UI] Failed to load Forge site loader.",
error,
);
});
})();
</script>
</head>

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
PREP(buildStoreUIPayload);
PREP(handleUIEvents);
PREP(initStoreCatalogService);
PREP(initStoreClass);
PREP(initStoreUIBridge);
PREP(openUI);

View File

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

View File

@ -8,6 +8,7 @@ class CfgPatches {
name = COMPONENT_NAME;
requiredVersion = REQUIRED_VERSION;
requiredAddons[] = {
"forge_client_common",
"forge_client_main"
};
units[] = {};

View File

@ -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", []]
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
<!-- Generated by tools/build-webui.mjs for store UI index. Do not edit directly. -->
<!doctype html>
<html lang="en">
<head>
@ -5,69 +6,55 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FORGE Supply Exchange</title>
<script>
const addonRoot = "forge\\forge_client\\addons\\store\\ui\\_site\\";
const styleFiles = ["style.css"];
const scriptFiles = [
"runtime.js",
"media.js",
"data.js",
"logic/store.js",
"pages/StoreView.js",
"useStore.js",
"bridge.js",
"logic/events.js",
"components/AppShell.js",
"components/cards.js",
"components/cart.js",
"components/navbar.js",
"script.js",
];
window.ForgeSiteConfig = {
addonName: "store",
logLabel: "Store UI",
styles: ["store-ui.css"],
commonScripts: ["forge-webui.js"],
scripts: ["store-ui.js"],
};
function requestText(path) {
if (
typeof A3API !== "undefined" &&
typeof A3API.RequestFile === "function"
) {
return A3API.RequestFile(
addonRoot + path.replace(/\//g, "\\"),
);
(function loadForgeSiteLoader() {
const armaLoaderPath =
"forge\\forge_client\\addons\\common\\ui\\_site\\forge-site-loader.js";
const browserLoaderPath =
"../../../common/ui/_site/forge-site-loader.js";
function appendScript(js) {
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
}
return fetch(path).then((response) => {
if (!response.ok) {
throw new Error("Failed to load " + path);
function requestLoader() {
if (
typeof A3API !== "undefined" &&
A3API &&
typeof A3API.RequestFile === "function"
) {
return A3API.RequestFile(armaLoaderPath);
}
return response.text();
});
}
return fetch(browserLoaderPath).then((response) => {
if (!response.ok) {
throw new Error(
"Failed to load " + browserLoaderPath,
);
}
function appendStyle(css) {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
return response.text();
});
}
function appendScript(js) {
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
}
Promise.all(styleFiles.map(requestText))
.then((styles) => {
styles.forEach(appendStyle);
return Promise.all(scriptFiles.map(requestText));
})
.then((scripts) => {
scripts.forEach(appendScript);
})
.catch((error) => {
console.error(
"[Store UI] Failed to load site assets.",
error,
);
});
requestLoader()
.then(appendScript)
.catch((error) => {
console.error(
"[Store UI] Failed to load Forge site loader.",
error,
);
});
})();
</script>
</head>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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}.` : "."}`,
),
),
),

View File

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

View File

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

View File

@ -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 "";

View File

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

View File

@ -0,0 +1,6 @@
(function () {
const runtime = window.ForgeWebUI;
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
StorefrontApp.runtime = runtime;
window.AppRuntime = runtime;
})();

View File

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

View File

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

View File

@ -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"
}

7
package.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "forge-webui",
"private": true,
"scripts": {
"build:webui": "node tools/build-webui.mjs"
}
}

335
tools/build-webui.mjs Normal file
View File

@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<script>
window.ForgeSiteConfig = ${configJson};
(function loadForgeSiteLoader() {
const armaLoaderPath =
"forge\\\\forge_client\\\\addons\\\\common\\\\ui\\\\_site\\\\forge-site-loader.js";
const browserLoaderPath =
"../../../common/ui/_site/forge-site-loader.js";
function appendScript(js) {
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
}
function requestLoader() {
if (
typeof A3API !== "undefined" &&
A3API &&
typeof A3API.RequestFile === "function"
) {
return A3API.RequestFile(armaLoaderPath);
}
return fetch(browserLoaderPath).then((response) => {
if (!response.ok) {
throw new Error(
"Failed to load " + browserLoaderPath,
);
}
return response.text();
});
}
requestLoader()
.then(appendScript)
.catch((error) => {
console.error(
"[${siteConfig.logLabel}] Failed to load Forge site loader.",
error,
);
});
})();
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>
`;
}
async function buildHtmlPage({ name, output, title, siteConfig }) {
const banner = `<!-- Generated by tools/build-webui.mjs for ${name}. Do not edit directly. -->\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;
});