Refactor bank UI to bridge-driven single-page flow

- Replace separate bank/ATM pages with a unified `index.html` app bundle
- Split bank init into `initClass`, `initSessionService`, and `initUIBridge`
- Route UI events through `BankUIBridge` and refresh session payloads after sync
This commit is contained in:
Jacob Schmidt 2026-03-14 12:11:34 -05:00
parent bdc1e36e63
commit 603963c935
47 changed files with 5089 additions and 2381 deletions

View File

@ -1,3 +1,5 @@
PREP(handleUIEvents);
PREP(initBankClass);
PREP(initClass);
PREP(initSessionService);
PREP(initUIBridge);
PREP(openUI);

View File

@ -1,6 +1,8 @@
#include "script_component.hpp"
if (isNil QGVAR(BankClass)) then { call FUNC(initBankClass); };
if (isNil QGVAR(BankClass)) then { call FUNC(initClass); };
if (isNil QGVAR(BankSessionService)) then { call FUNC(initSessionService); };
if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); };
[QGVAR(initBank), {
GVAR(BankClass) call ["init", []];
@ -10,12 +12,18 @@ if (isNil QGVAR(BankClass)) then { call FUNC(initBankClass); };
params [["_data", createHashMap, [createHashMap]]];
GVAR(BankClass) call ["sync", [_data, true]];
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["refreshSession", []];
};
}] call CFUNC(addEventHandler);
[QGVAR(responseSyncBank), {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
GVAR(BankClass) call ["sync", [_data, _jip]];
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["refreshSession", []];
};
}] call CFUNC(addEventHandler);
[{

View File

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

View File

@ -28,90 +28,49 @@ private _alert = fromJSON _message;
private _event = _alert get "event";
private _data = _alert get "data";
private _uid = GVAR(BankClass) get "uid";
private _account = GVAR(BankClass) get "account";
private _bank = _account get "bank";
private _cash = _account get "cash";
private _earnings = _account get "earnings";
private _pin = _account get "pin";
private _funds = EGVAR(org,OrgClass) get "funds";
diag_log format ["[FORGE:Client:Bank] Handling UI event: %1 with data: %2", _event, _data];
switch (_event) do {
// ========================================================================
// DATA REQUESTS
// ========================================================================
case "bank::sync": {
private _players = SREG(bank,IndexRegistry);
private _accountData = createHashMapFromArray [
["uid", _uid],
["bank", _bank],
["cash", _cash],
["earnings", _earnings],
["org", _funds],
["pin", _pin],
["players", _players]
];
_control ctrlWebBrowserAction ["ExecJS", format ["syncDataFromArma(%1)", toJSON _accountData]];
};
// ========================================================================
// BANK OPERATIONS
// ========================================================================
case "bank::deposit": {
private _amount = _data get "amount";
if (_amount > _cash) exitWith { hint "Insufficient cash!"; };
[SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent);
};
case "bank::withdraw": {
private _amount = _data get "amount";
if (_amount > _bank) exitWith { hint "Insufficient funds!"; };
[SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent);
};
case "bank::transfer": {
private _amount = _data get "amount";
private _from = _data get "from";
private _target = _data get "target";
if (_target isEqualTo _uid) exitWith {
hint "Cannot transfer to yourself!";
diag_log "[FORGE:Client:Bank] Attempted self-transfer blocked";
case "bank::close": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleClose", []];
};
private _fromAmount = _account get _from;
if (_amount > _fromAmount) exitWith { hint "Insufficient funds!"; };
[SRPC(bank,requestTransfer), [_uid, _target, _from, _amount]] call CFUNC(serverEvent);
closeDialog 1;
};
case "bank::depositEarnings": {
private _amount = _data get "amount";
if (_amount > _earnings) exitWith { hint "Insufficient earnings!"; };
[SRPC(bank,requestDepositEarnings), [_uid, _amount]] call CFUNC(serverEvent);
case "bank::ready": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleReady", [_control, _data]];
};
};
case "bank::close": { closeDialog 1; };
// ========================================================================
// ATM OPERATIONS
// ========================================================================
case "atm::withdraw": {
private _amount = _data get "amount";
if (_amount > _bank) exitWith { hint "Insufficient funds!"; };
[SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent);
case "bank::refresh": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["refreshSession", []];
};
};
case "atm::deposit": {
private _amount = _data get "amount";
if (_amount > _cash) exitWith { hint "Insufficient cash!"; };
[SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent);
case "bank::deposit::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleDepositRequest", [_data]];
};
};
case "bank::withdraw::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleWithdrawRequest", [_data]];
};
};
case "bank::transfer::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleTransferRequest", [_data]];
};
};
case "bank::depositEarnings::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]];
};
};
default {
hint format ["Unhandled bank UI event: %1", _event];
};
case "atm::close": { closeDialog 1; };
default { diag_log format ["[FORGE:Client:Bank] Unhandled UI event: %1", _event]; };
};
true;

View File

@ -1,69 +0,0 @@
#include "..\script_component.hpp"
/*
* File: fnc_initBankClass.sqf
* Author: IDSolutions
* Date: 2025-12-16
* Last Update: 2026-02-13
* Public: No
*
* Description:
* Initializes the bank class.
*
* Arguments:
* None
*
* Return Value:
* Bank class object [HASHMAP OBJECT]
*
* Example:
* call forge_client_bank_fnc_initBankClass
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(BankBaseClass) = compileFinal createHashMapFromArray [
["#type", "BankBaseClass"],
["#create", compileFinal {
_self set ["uid", getPlayerUID player];
_self set ["account", createHashMap];
_self set ["isLoaded", false];
_self set ["lastSave", time];
}],
["init", compileFinal {
private _uid = _self get "uid";
[SRPC(bank,requestInitBank), [_uid]] call CFUNC(serverEvent);
systemChat format ["Bank loaded for %1", (name player)];
diag_log "[FORGE:Client:Bank] Bank Class Initialized!";
}],
["save", compileFinal {
params [["_sync", false, [false]]];
private _uid = _self get "uid";
[SRPC(bank,requestSaveBank), [_uid, _sync]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["sync", compileFinal {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
private _account = _self get "account";
private _isLoaded = _self get "isLoaded";
{ _account set [_x, _y]; } forEach _data;
_self set ["account", _account];
if !(_isLoaded) then { _self set ["isLoaded", true]; };
diag_log "[FORGE:Client:Bank] Sync completed";
}],
["get", compileFinal {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _account = _self get "account";
_account getOrDefault [_key, _default];
}]
];
GVAR(BankClass) = createHashMapObject [GVAR(BankBaseClass)];
GVAR(BankClass)

View File

@ -0,0 +1,62 @@
#include "..\script_component.hpp"
/*
* File: fnc_initClass.sqf
* Author: IDSolutions
* Public: No
*
* Description:
* Initializes the bank class for account sync and access helpers.
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(BankBaseClass) = compileFinal createHashMapFromArray [
["#type", "BankBaseClass"],
["#create", compileFinal {
_self set ["uid", getPlayerUID player];
_self set ["account", createHashMapFromArray [
["bank", 0],
["cash", 0],
["earnings", 0],
["pin", 1234],
["transactions", []]
]];
_self set ["isLoaded", false];
_self set ["lastSave", time];
}],
["getAccountState", compileFinal {
_self getOrDefault ["account", createHashMap]
}],
["get", compileFinal {
params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
private _account = _self getOrDefault ["account", createHashMap];
_account getOrDefault [_key, _default]
}],
["init", compileFinal {
[SRPC(bank,requestInitBank), [getPlayerUID player]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["save", compileFinal {
[SRPC(bank,requestSaveBank), [getPlayerUID player]] call CFUNC(serverEvent);
_self set ["lastSave", time];
}],
["sync", compileFinal {
params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]];
private _account = _self getOrDefault ["account", createHashMap];
{
_account set [_x, _y];
} forEach _data;
_self set ["account", _account];
if !(_self getOrDefault ["isLoaded", false]) then {
_self set ["isLoaded", true];
};
true
}]
];
GVAR(BankClass) = createHashMapObject [GVAR(BankBaseClass)];
GVAR(BankClass)

View File

@ -0,0 +1,80 @@
#include "..\script_component.hpp"
/*
* File: fnc_initSessionService.sqf
* Author: IDSolutions
* Public: No
*
* Description:
* Initializes the bank session service that shapes the browser payload.
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(BankSessionServiceBaseClass) = compileFinal createHashMapFromArray [
["#type", "BankSessionServiceBaseClass"],
["buildTransferTargets", compileFinal {
private _targets = [];
{
if (isNull _x || { _x isEqualTo player }) then {
continue;
};
private _uid = getPlayerUID _x;
private _name = name _x;
if (_uid isEqualTo "" || { _name isEqualTo "" }) then {
continue;
};
_targets pushBack (createHashMapFromArray [
["name", _name],
["uid", _uid]
]);
} forEach allPlayers;
private _targetPairs = _targets apply {
[toLowerANSI (_x getOrDefault ["name", ""]), _x]
};
_targetPairs sort true;
_targetPairs apply {
_x param [1, createHashMap]
}
}],
["buildPayload", compileFinal {
params [["_mode", "bank", [""]]];
private _account = if (isNil QGVAR(BankClass)) then {
createHashMap
} else {
GVAR(BankClass) call ["getAccountState", []]
};
private _orgFunds = 0;
private _orgName = "";
if !(isNil QEGVAR(org,OrgClass)) then {
_orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]];
_orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]];
};
createHashMapFromArray [
["session", createHashMapFromArray [
["mode", ["bank", "atm"] select (toLowerANSI _mode isEqualTo "atm")],
["orgFunds", _orgFunds],
["orgName", _orgName],
["playerName", name player],
["transferTargets", _self call ["buildTransferTargets", []]],
["uid", getPlayerUID player]
]],
["account", createHashMapFromArray [
["bank", _account getOrDefault ["bank", 0]],
["cash", _account getOrDefault ["cash", 0]],
["earnings", _account getOrDefault ["earnings", 0]],
["pin", str (_account getOrDefault ["pin", 1234])],
["transactions", _account getOrDefault ["transactions", []]]
]]
]
}]
];
GVAR(BankSessionService) = createHashMapObject [GVAR(BankSessionServiceBaseClass)];
GVAR(BankSessionService)

View File

@ -0,0 +1,134 @@
#include "..\script_component.hpp"
/*
* File: fnc_initUIBridge.sqf
* Author: IDSolutions
* Public: No
*
* Description:
* Initializes the bank web UI bridge.
*/
#pragma hemtt ignore_variables ["_self"]
private _webUIDeclarations = call EFUNC(common,initWebUIBridge);
private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration";
GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [
["#base", _webUIBridgeDeclaration],
["#type", "BankUIBridgeBaseClass"],
["#create", compileFinal {
_self set ["mode", "bank"];
}],
["buildPayload", compileFinal {
GVAR(BankSessionService) call ["buildPayload", [_self call ["getMode", []]]]
}],
["getActiveBrowserControl", compileFinal {
private _display = uiNamespace getVariable ["RscBank", displayNull];
if (isNull _display) exitWith {
_self call ["setActiveBrowserControl", [controlNull]];
controlNull
};
private _control = _display displayCtrl 1002;
_self call ["setActiveBrowserControl", [_control]];
_control
}],
["getMode", compileFinal {
_self getOrDefault ["mode", "bank"]
}],
["handleDepositEarningsRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "No earnings are available to deposit."]];
};
[SRPC(bank,requestDepositEarnings), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["handleDepositRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "Enter a valid deposit amount."]];
};
[SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["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", []];
_self call ["sendEvent", ["bank::hydrate", _self call ["buildPayload", []], _control]];
}],
["handleTransferRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
private _target = _data getOrDefault ["target", ""];
private _from = toLowerANSI (_data getOrDefault ["from", "bank"]);
if (_target isEqualTo "") exitWith {
_self call ["sendNotice", ["error", "Select a transfer recipient."]];
};
if (_target isEqualTo getPlayerUID player) exitWith {
_self call ["sendNotice", ["error", "You cannot transfer funds to yourself."]];
};
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "Enter a valid transfer amount."]];
};
[SRPC(bank,requestTransfer), [getPlayerUID player, _target, _from, _amount]] call CFUNC(serverEvent);
true
}],
["handleWithdrawRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _amount = floor (_data getOrDefault ["amount", 0]);
if (_amount <= 0) exitWith {
_self call ["sendNotice", ["error", "Enter a valid withdrawal amount."]];
};
[SRPC(bank,requestWithdraw), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["refreshSession", compileFinal {
private _control = _self call ["getActiveBrowserControl", []];
if (isNull _control) exitWith { false };
_self call ["sendEvent", ["bank::sync", _self call ["buildPayload", []], _control]]
}],
["sendNotice", compileFinal {
params [["_type", "error", [""]], ["_message", "", [""]], ["_control", controlNull, [controlNull]]];
if (_message isEqualTo "") exitWith { false };
_self call ["sendEvent", ["bank::notice", createHashMapFromArray [
["message", _message],
["type", _type]
], _control]]
}],
["setMode", compileFinal {
params [["_mode", "bank", [""]]];
private _finalMode = toLowerANSI _mode;
if !(_finalMode in ["bank", "atm"]) then {
_finalMode = "bank";
};
_self set ["mode", _finalMode];
_finalMode
}]
];
GVAR(BankUIBridge) = createHashMapObject [GVAR(BankUIBridgeBaseClass)];
GVAR(BankUIBridge)

View File

@ -31,11 +31,11 @@ _ctrl ctrlAddEventHandler ["JSDialog", {
[_control, _isConfirmDialog, _message] call FUNC(handleUIEvents);
}];
if (_isATM) then {
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\atm.html)];
} else {
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\bank.html)];
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["setMode", [["bank", "atm"] select _isATM]];
GVAR(BankUIBridge) call ["setActiveBrowserControl", [_ctrl]];
};
// _ctrl ctrlWebBrowserAction ["OpenDevConsole"];
_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)];
true;

View File

@ -1,192 +0,0 @@
: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;
}
body {
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
margin: 0;
padding: 0;
background: transparent;
color: var(--text-main);
line-height: 1.6;
}
#app {
min-height: 100vh;
}
main {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 3rem 0;
box-sizing: border-box;
}
.container {
max-width: 800px;
width: 100%;
background: #f1f5f9;
margin: 0 auto;
padding: 2rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
box-sizing: border-box;
}
/* Header */
.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;
margin: 0;
}
}
/* Cards */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
text-align: center;
h2 {
margin-top: 0;
font-size: 1.8rem;
color: var(--primary-hover);
}
}
/* PIN Display */
.pin-display {
font-size: 2.5rem;
letter-spacing: 0.5rem;
text-align: center;
margin-bottom: 2rem;
font-family: monospace;
color: var(--primary);
}
/* Numpad */
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
max-width: 300px;
margin: 0 auto;
button {
padding: 1.5rem;
font-size: 1.5rem;
background: var(--bg-surface);
color: var(--text-main);
border: 1px solid var(--border);
box-shadow: var(--shadow);
margin: 0;
&:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
}
}
/* Kiosk Content */
.kiosk-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
/* Kiosk Grid */
.kiosk-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-top: 2rem;
width: 100%;
max-width: 600px;
}
/* Kiosk Menu Stack */
.kiosk-menu-stack {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 2rem;
width: 100%;
max-width: 600px;
}
/* Kiosk Button */
.kiosk-btn {
padding: 2rem;
font-size: 1.25rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
min-height: 120px;
margin: 0;
}
/* Buttons */
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;
font-family: inherit;
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);
}
& + & {
margin-left: 1rem;
}
}

View File

@ -1,45 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ATM</title>
<!-- <link rel="stylesheet" href="atm.css"> -->
<!--
Dynamic Resource Loading
The following script loads CSS and JavaScript files dynamically using the A3API
This approach is used instead of static HTML imports to work with Arma 3's file system
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\atm.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\atm.js",
),
]).then(([css, storeJs, atmJs]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const store = document.createElement("script");
store.text = storeJs;
document.head.appendChild(store);
const atm = document.createElement("script");
atm.text = atmJs;
document.head.appendChild(atm);
});
</script>
</head>
<body>
<div id="app"></div>
<!-- <script src="store.js"></script> -->
<!-- <script src="atm.js"></script> -->
</body>
</html>

View File

@ -1,490 +0,0 @@
/**
* ATM App - Vanilla JS Kiosk Implementation
*/
//=============================================================================
// #region LIBRARY - DOM Helper
//=============================================================================
function h(tag, props = {}, ...children) {
const el = 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);
} else if (key === "className") {
el.className = value;
} else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value);
} else {
el.setAttribute(key, value);
}
});
}
children.forEach((child) => {
if (typeof child === "string" || typeof child === "number") {
el.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
el.appendChild(child);
} else if (Array.isArray(child)) {
child.forEach((c) => {
if (c instanceof Node) el.appendChild(c);
});
}
});
return el;
}
let _rootContainer = null;
let _rootComponent = null;
function render(component, container) {
_rootContainer = container;
_rootComponent = component;
_render();
}
function _render() {
if (_rootContainer && _rootComponent) {
_rootContainer.innerHTML = "";
_rootContainer.appendChild(_rootComponent());
}
}
const createSignal = (initialValue) => {
let _val = initialValue;
const getValue = () => _val;
const setValue = (newValue) => {
_val = typeof newValue === "function" ? newValue(_val) : newValue;
_render();
};
return [getValue, setValue];
};
//=============================================================================
// #region STATE
//=============================================================================
const [getView, setView] = createSignal("pin"); // 'pin', 'menu', 'withdraw', 'custom_withdraw', 'balance'
const [getPin, setPin] = createSignal("");
const [getCustomAmount, setCustomAmount] = createSignal("");
const [getMessage, setMessage] = createSignal("");
//=============================================================================
// #region UI COMPONENTS
//=============================================================================
function Header() {
return h(
"div",
{ className: "header", style: { marginBottom: "2rem" } },
h("h1", null, "ATM TERMINAL"),
h("p", null, "Global Financial Network"),
);
}
function PinView() {
const currentPin = getPin();
const handleNumClick = (num) => {
if (currentPin.length < 4) {
setPin((prev) => prev + num);
}
};
const handleClear = () => setPin("");
const handleEnter = () => {
if (currentPin.length === 4) {
const state =
typeof store !== "undefined"
? store.getState()
: { pin: "1234" };
if (currentPin === state.pin) {
setView("menu");
} else {
setMessage("Incorrect PIN");
setPin("");
setTimeout(() => setMessage(""), 2000);
}
} else {
setMessage("Invalid PIN Length");
setTimeout(() => setMessage(""), 2000);
}
};
return h(
"div",
{ className: "card", style: { padding: "3rem 2rem" } },
h("h2", null, "Enter Security PIN"),
h(
"div",
{ className: "pin-display" },
currentPin.replace(/./g, String.fromCharCode(8226)) || "----",
),
h(
"p",
{
style: {
color: "#ef4444",
height: "1.5rem",
textAlign: "center",
},
},
getMessage(),
),
h(
"div",
{ className: "numpad" },
["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((num) =>
h("button", { onClick: () => handleNumClick(num) }, num),
),
h(
"button",
{
style: { background: "#ef4444", color: "white" },
onClick: handleClear,
},
"C",
),
h("button", { onClick: () => handleNumClick("0") }, "0"),
h(
"button",
{
style: { background: "#10b981", color: "white" },
onClick: handleEnter,
},
String.fromCharCode(8629),
),
),
);
}
function MenuView() {
return h(
"div",
{ className: "kiosk-content" },
h(
"h2",
{ style: { textAlign: "center", marginBottom: "1rem" } },
"Select Transaction",
),
h(
"div",
{ className: "kiosk-menu-stack" },
h(
"button",
{ className: "kiosk-btn", onClick: () => setView("withdraw") },
"Withdraw Cash",
),
h(
"button",
{ className: "kiosk-btn", onClick: () => setView("balance") },
"Check Balance",
),
h(
"button",
{
className: "kiosk-btn",
style: {
background: "var(--bg-surface)",
color: "var(--text-main)",
border: "1px solid var(--border)",
},
onClick: () => {
setPin("");
setView("pin");
sendEvent("atm::close", {});
},
},
"Cancel Transaction",
),
),
);
}
function WithdrawView() {
const state =
typeof store !== "undefined"
? store.getState()
: { accounts: { bank: 0 } };
const bankBalance = state.accounts?.bank || 0;
const handleWithdraw = (amount) => {
if (bankBalance >= amount) {
if (typeof store !== "undefined") {
store.dispatch(withdraw(amount));
}
sendEvent("atm::withdraw", { amount });
setMessage(`Please take your cash: $${amount.toLocaleString()}`);
setTimeout(() => {
setMessage("");
setView("menu");
}, 3000);
} else {
setMessage("Insufficient Funds");
setTimeout(() => setMessage(""), 2000);
}
};
if (getMessage()) {
return h(
"div",
{
className: "card",
style: { padding: "4rem", textAlign: "center" },
},
h("h2", { style: { color: "var(--primary)" } }, getMessage()),
);
}
return h(
"div",
{ className: "kiosk-content" },
h(
"h2",
{ style: { textAlign: "center", marginBottom: "1rem" } },
"Select Amount",
),
h(
"div",
{ className: "kiosk-grid" },
h(
"button",
{ className: "kiosk-btn", onClick: () => handleWithdraw(20) },
"$20",
),
h(
"button",
{ className: "kiosk-btn", onClick: () => handleWithdraw(50) },
"$50",
),
h(
"button",
{ className: "kiosk-btn", onClick: () => handleWithdraw(100) },
"$100",
),
h(
"button",
{
className: "kiosk-btn",
onClick: () => {
setCustomAmount("");
setView("custom_withdraw");
},
},
"Other Amount",
),
h(
"button",
{
className: "kiosk-btn",
style: {
gridColumn: "span 2",
background: "var(--text-muted)",
},
onClick: () => setView("menu"),
},
"Cancel",
),
),
);
}
function CustomWithdrawView() {
const currentAmount = getCustomAmount();
const state =
typeof store !== "undefined"
? store.getState()
: { accounts: { bank: 0 } };
const bankBalance = state.accounts?.bank || 0;
const handleNumClick = (num) => {
if (currentAmount.length < 5) {
setCustomAmount((prev) => prev + num);
}
};
const handleClear = () => setCustomAmount("");
const handleEnter = () => {
const amount = parseInt(currentAmount, 10);
if (amount > 0) {
if (bankBalance >= amount) {
if (typeof store !== "undefined") {
store.dispatch(withdraw(amount));
}
sendEvent("atm::withdraw", { amount });
setMessage(
`Please take your cash: $${amount.toLocaleString()}`,
);
setTimeout(() => {
setMessage("");
setView("menu");
}, 3000);
} else {
setMessage("Insufficient Funds");
setTimeout(() => setMessage(""), 2000);
}
} else {
setMessage("Invalid Amount");
setTimeout(() => setMessage(""), 2000);
}
};
if (getMessage()) {
return h(
"div",
{
className: "card",
style: { padding: "4rem", textAlign: "center" },
},
h("h2", { style: { color: "var(--primary)" } }, getMessage()),
);
}
return h(
"div",
{ className: "card", style: { padding: "3rem 2rem" } },
h("h2", null, "Enter Amount"),
h(
"div",
{ className: "pin-display" },
currentAmount ? `$${currentAmount}` : "$0",
),
h(
"div",
{ className: "numpad" },
["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((num) =>
h("button", { onClick: () => handleNumClick(num) }, num),
),
h(
"button",
{
style: { background: "#ef4444", color: "white" },
onClick: handleClear,
},
"C",
),
h("button", { onClick: () => handleNumClick("0") }, "0"),
h(
"button",
{
style: { background: "#10b981", color: "white" },
onClick: handleEnter,
},
String.fromCharCode(8629),
),
),
h(
"button",
{
style: {
width: "100%",
marginTop: "2rem",
padding: "1rem",
background: "var(--text-muted)",
},
onClick: () => setView("withdraw"),
},
"Cancel",
),
);
}
function BalanceView() {
const state =
typeof store !== "undefined"
? store.getState()
: { accounts: { bank: 0 } };
const bankBalance = state.accounts?.bank || 0;
return h(
"div",
{ className: "card", style: { textAlign: "center", padding: "3rem" } },
h("h2", { style: { color: "var(--text-muted)" } }, "Available Balance"),
h(
"div",
{
style: {
fontSize: "4rem",
fontWeight: "800",
margin: "2rem 0",
color: "var(--primary-hover)",
},
},
"$" + bankBalance.toLocaleString(),
),
h(
"button",
{
className: "kiosk-btn",
style: { width: "100%", maxWidth: "300px", margin: "0 auto" },
onClick: () => setView("menu"),
},
"Return to Menu",
),
);
}
function App() {
const view = getView();
let mainContent;
if (view === "pin") {
mainContent = PinView();
} else if (view === "menu") {
mainContent = MenuView();
} else if (view === "withdraw") {
mainContent = WithdrawView();
} else if (view === "custom_withdraw") {
mainContent = CustomWithdrawView();
} else if (view === "balance") {
mainContent = BalanceView();
}
return h(
"main",
null,
h("div", { className: "container" }, Header(), mainContent),
);
}
//=============================================================================
// #region ARMA 3 INTEGRATION
//=============================================================================
function sendEvent(event, data) {
if (typeof A3API !== "undefined") {
A3API.SendAlert(JSON.stringify({ event, data }));
} else {
console.log("Event:", event, "Data:", data);
}
}
//=============================================================================
// #region INITIALIZATION
//=============================================================================
let initialized = false;
function initATM() {
if (initialized) return;
const root = document.getElementById("app");
if (root) {
if (typeof store !== "undefined") {
store.subscribe(() => _render());
}
render(App, root);
initialized = true;
console.log("[ATM] Interface initialized");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initATM);
} else {
initATM();
}

View File

@ -0,0 +1,591 @@
/* Generated by tools/build-webui.mjs for Bank UI styles. Do not edit directly. */
:root {
--bank-shell-bg: #f6f4ee;
--bank-surface: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
--bank-border: rgba(18, 54, 93, 0.12);
--bank-border-strong: rgba(18, 54, 93, 0.18);
--bank-text-main: #142f52;
--bank-text-muted: #6f86a3;
--bank-text-subtle: #8ea2bb;
--bank-accent: #275a8c;
--bank-accent-soft: #dfeaf9;
--bank-accent-line: rgba(39, 90, 140, 0.12);
--bank-shadow: 0 16px 30px rgba(18, 36, 57, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background: transparent;
color: var(--bank-text-main);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
button,
input,
select {
font: inherit;
}
.bank-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--bank-shell-bg);
}
.bank-scroll-shell {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.bank-layout {
min-height: 100%;
width: min(100%, 1600px);
margin: 0 auto;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 1.25rem;
padding: 1.25rem;
flex: 1 0 auto;
}
.bank-sidebar,
.bank-main {
min-height: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bank-main {
overflow: visible;
}
.bank-module,
.bank-card,
.bank-atm-panel {
background: var(--bank-surface);
border: 1px solid var(--bank-border);
border-radius: 1.3rem;
box-shadow: var(--bank-shadow);
}
.bank-module,
.bank-card,
.bank-atm-panel {
padding: 1rem;
display: flex;
flex-direction: column;
}
.bank-module-header,
.bank-card-header,
.bank-section-header,
.bank-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.bank-module-header,
.bank-card-header {
margin-bottom: 0.9rem;
}
.bank-page {
display: grid;
gap: 1.35rem;
padding: 0.1rem 0 0;
}
.bank-page-header {
padding-top: 0.4rem;
}
.bank-page-copy {
margin: 0;
color: var(--bank-text-muted);
line-height: 1.5;
max-width: 48rem;
}
.bank-page-divider {
border-top: 1px solid var(--bank-accent-line);
}
.bank-page-body {
display: grid;
gap: 1.25rem;
padding-bottom: 1.25rem;
}
.bank-page-section {
display: grid;
gap: 1rem;
padding: 1.15rem 1.2rem 1.25rem;
border: 1px solid var(--bank-border);
border-radius: 1.3rem;
background: rgba(255, 255, 255, 0.72);
box-shadow: none;
}
.bank-title,
.bank-section-title {
margin: 0;
color: var(--bank-text-main);
letter-spacing: -0.02em;
}
.bank-title {
font-size: 1.7rem;
}
.bank-section-title {
font-size: 1.1rem;
}
.bank-eyebrow,
.bank-footer-title,
.bank-stat-label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
color: var(--bank-text-subtle);
}
.bank-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.48rem 0.8rem;
border-radius: 999px;
background: var(--bank-accent-soft);
color: var(--bank-accent);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.bank-summary-grid,
.bank-profile-stack {
display: grid;
gap: 0.8rem;
}
.bank-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bank-stat-card,
.bank-metric-card {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.9rem;
border-radius: 0.95rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.6);
}
.bank-stat-card.is-accent,
.bank-metric-card.is-accent {
background: linear-gradient(180deg, #edf4fe 0%, #dfeaf9 100%);
}
.bank-stat-card.is-success,
.bank-metric-card.is-success {
background: linear-gradient(180deg, #edf9f4 0%, #dff4ea 100%);
}
.bank-stat-card.is-warning,
.bank-metric-card.is-warning {
background: linear-gradient(180deg, #fdf7ea 0%, #f7edd4 100%);
}
.bank-stat-value,
.bank-metric-value {
min-width: 0;
color: var(--bank-text-main);
font-weight: 700;
overflow-wrap: anywhere;
}
.bank-stat-value {
font-size: 1rem;
}
.bank-metric-value {
font-size: 1.8rem;
letter-spacing: -0.03em;
}
.bank-metric-copy,
.bank-card-copy,
.bank-empty-copy,
.bank-footer-copy,
.bank-history-meta {
color: var(--bank-text-muted);
line-height: 1.45;
}
.bank-card-copy {
margin: 0 0 0.9rem;
}
.bank-summary-band {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.bank-action-sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.bank-support-sections {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
}
.bank-form-stack {
display: grid;
gap: 0.75rem;
}
.bank-input,
.bank-select {
width: 100%;
min-width: 0;
height: 2.9rem;
padding: 0 0.95rem;
border-radius: 0.8rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.82);
color: var(--bank-text-main);
}
.bank-action-row {
display: flex;
gap: 0.75rem;
}
.bank-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.75rem 1rem;
border-radius: 0.8rem;
border: 1px solid var(--bank-border);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease,
border-color 160ms ease;
}
.bank-btn:disabled {
opacity: 0.55;
cursor: default;
}
.bank-btn-primary {
background: #455a77;
border-color: #455a77;
color: #fff;
}
.bank-btn-primary:hover:not(:disabled) {
background: #354863;
border-color: #354863;
}
.bank-btn-secondary {
background: rgba(255, 255, 255, 0.82);
color: var(--bank-accent);
}
.bank-btn-secondary:hover:not(:disabled) {
background: #eef4fd;
}
.bank-history-list {
display: grid;
gap: 0.75rem;
}
.bank-history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.95rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.6);
}
.bank-history-copy {
min-width: 0;
display: grid;
gap: 0.18rem;
}
.bank-history-title,
.bank-empty-title {
color: var(--bank-text-main);
font-weight: 700;
}
.bank-history-value {
white-space: nowrap;
font-weight: 700;
color: var(--bank-accent);
}
.bank-empty-state {
display: grid;
gap: 0.35rem;
padding: 1rem 0;
}
.bank-notice-stack {
position: fixed;
top: 1.2rem;
right: 1.5rem;
z-index: 12;
display: grid;
gap: 0.65rem;
}
.bank-notice {
max-width: 24rem;
padding: 0.85rem 1rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: #fff;
box-shadow: 0 14px 28px rgba(16, 34, 56, 0.14);
font-size: 0.92rem;
}
.bank-notice.is-success {
background: #ecfdf5;
border-color: #bbf7d0;
color: #166534;
}
.bank-notice.is-error {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.bank-footer-bar {
width: 100%;
margin-top: auto;
background: #1e293b;
color: #f8fafc;
}
.bank-footer {
width: min(100%, 1600px);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4rem;
padding: 3rem 1.25rem;
}
.bank-footer-block {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bank-footer-title {
margin: 0;
color: #f8fafc;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
padding-bottom: 0.5rem;
border-bottom: 1px solid #475569;
}
.bank-footer-list {
margin: 0;
padding: 0;
list-style: none;
}
.bank-atm-shell {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.bank-atm-panel {
width: min(100%, 560px);
display: grid;
gap: 1rem;
}
.bank-atm-stack {
display: grid;
gap: 1rem;
}
.bank-pin-display,
.bank-balance-display {
display: flex;
align-items: center;
justify-content: center;
min-height: 5rem;
padding: 1rem;
border-radius: 1rem;
border: 1px solid var(--bank-border-strong);
background: rgba(255, 255, 255, 0.68);
color: var(--bank-text-main);
text-align: center;
}
.bank-pin-display {
font-size: 2rem;
}
.bank-balance-display {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.bank-pin-indicators {
display: flex;
align-items: center;
justify-content: center;
gap: 0.9rem;
}
.bank-pin-indicator {
width: 1rem;
height: 1rem;
border-radius: 999px;
border: 2px solid var(--bank-accent);
background: transparent;
}
.bank-pin-indicator.is-filled {
background: var(--bank-accent);
}
.bank-keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.bank-key {
min-height: 3.2rem;
padding: 0.9rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.82);
color: var(--bank-text-main);
font-weight: 700;
}
.bank-key.is-muted {
background: #eef2f8;
color: var(--bank-text-muted);
}
.bank-key.is-accent {
background: #455a77;
border-color: #455a77;
color: #fff;
}
.bank-key.is-wide {
grid-column: span 3;
}
.bank-atm-action-grid {
display: grid;
gap: 0.75rem;
}
.bank-shell.is-atm {
background: transparent;
min-height: 100%;
justify-content: center;
}
.bank-shell.is-atm .bank-atm-shell {
flex: 1;
width: 100%;
min-height: 100%;
max-width: 100%;
}
.bank-footer-copy {
color: #cbd5e1;
line-height: 1.5;
margin: 0 0 0.75rem;
}
@media (max-width: 1200px) {
.bank-layout {
grid-template-columns: 1fr;
}
.bank-main {
overflow: visible;
}
}
@media (max-width: 900px) {
.bank-summary-band,
.bank-action-sections,
.bank-footer {
grid-template-columns: 1fr;
}
.bank-summary-grid {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,444 +0,0 @@
: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;
--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%;
}
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;
}
main {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
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%;
margin: 0 auto;
padding: 2rem;
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* Navbar */
.navbar {
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.navbar-inner {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 1rem 2rem;
box-sizing: border-box;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.navbar-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary-hover);
letter-spacing: -0.025em;
}
.navbar-profile {
display: flex;
align-items: center;
gap: 1.5rem;
}
.profile-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
}
.profile-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 500;
}
.profile-id {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-main);
font-family: "Consolas", "Monaco", monospace;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
/* Cards */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
text-align: center;
h2 {
margin-top: 0;
font-size: 1.8rem;
color: var(--primary-hover);
}
}
/* Buttons */
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;
font-family: inherit;
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);
}
& + & {
margin-left: 1rem;
}
}
/* Forms */
form {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: left;
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-muted);
font-weight: 500;
font-size: 0.9rem;
}
input,
select {
width: 100%;
padding: 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-app);
color: var(--text-main);
font-family: inherit;
font-size: 1rem;
box-sizing: border-box;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
}
.form-actions {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
}
/* Deposit/Withdraw Form */
.balance-info {
display: flex;
justify-content: space-around;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-surface-hover);
border-radius: var(--radius);
}
.balance-info-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.balance-info-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 500;
}
.balance-info-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary-hover);
&.cash {
color: #fbbf24;
}
}
.deposit-withdraw-form {
display: flex;
flex-direction: column;
gap: 1rem;
input {
text-align: center;
font-size: 1.25rem;
padding: 1rem;
}
}
.deposit-withdraw-buttons {
display: flex;
gap: 0.75rem;
button {
flex: 1;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: var(--primary);
transform: none;
box-shadow: none;
}
}
}
}
.deposit-earnings-button {
display: flex;
gap: 0.75rem;
width: 50%;
margin: 0 auto;
button {
flex: 1;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: var(--primary);
transform: none;
box-shadow: none;
}
}
}
}
/* Footer */
.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;
}
}
}
}
@media (max-width: 720px) {
.window-titlebar {
flex-direction: column;
align-items: flex-start;
}
.window-titlebar-controls {
width: 100%;
justify-content: flex-end;
}
}

View File

@ -1,45 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FDIC - Global Financial Network</title>
<!-- <link rel="stylesheet" href="bank.css"> -->
<!--
Dynamic Resource Loading
The following script loads CSS and JavaScript files dynamically using the A3API
This approach is used instead of static HTML imports to work with Arma 3's file system
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.js",
),
]).then(([css, storeJs, bankJs]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
const storeScript = document.createElement("script");
storeScript.text = storeJs;
document.head.appendChild(storeScript);
const bankScript = document.createElement("script");
bankScript.text = bankJs;
document.head.appendChild(bankScript);
});
</script>
</head>
<body>
<div id="app"></div>
<!-- <script src="store.js"></script> -->
<!-- <script src="bank.js"></script> -->
</body>
</html>

View File

@ -1,575 +0,0 @@
/**
* Bank App - Vanilla JS Implementation matching WIP UI
*/
//=============================================================================
// #region LIBRARY - DOM Helper
//=============================================================================
function h(tag, props = {}, ...children) {
const el = 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);
} else if (key === "className") {
el.className = value;
} else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value);
} else if (
key === "disabled" ||
key === "checked" ||
key === "selected" ||
key === "readonly"
) {
if (value) el[key] = true;
} else {
el.setAttribute(key, value);
}
});
}
children.forEach((child) => {
if (typeof child === "string" || typeof child === "number") {
el.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
el.appendChild(child);
} else if (Array.isArray(child)) {
child.forEach((c) => {
if (c instanceof Node) el.appendChild(c);
});
}
});
return el;
}
let _rootContainer = null;
let _rootComponent = null;
function render(component, container) {
_rootContainer = container;
_rootComponent = component;
_render();
}
function _render() {
if (_rootContainer && _rootComponent) {
_rootContainer.innerHTML = "";
_rootContainer.appendChild(_rootComponent());
}
}
//=============================================================================
// #region UI COMPONENTS
//=============================================================================
function Navbar() {
const state = store.getState();
const uid = state.uid || "Unknown";
return h(
"nav",
{ className: "navbar" },
h(
"div",
{ className: "navbar-inner" },
h(
"div",
{ className: "navbar-brand" },
h(
"span",
{ className: "navbar-title" },
"FDIC - Global Financial Network",
),
),
h(
"div",
{ className: "navbar-profile" },
h(
"div",
{ className: "profile-info" },
h("span", { className: "profile-label" }, "Account"),
h("span", { className: "profile-id" }, uid),
),
),
),
);
}
function WindowTitleBar() {
return h(
"div",
{ className: "window-titlebar" },
h(
"div",
{ className: "window-titlebar-brand" },
h(
"span",
{ className: "window-titlebar-kicker" },
"FDIC Workspace",
),
h(
"span",
{ className: "window-titlebar-title" },
"Global Financial Network",
),
),
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: () => sendEvent("bank::close", {}),
title: "Close",
"aria-label": "Close banking interface",
},
"X",
),
),
);
}
function TransactionHistory() {
const state = store.getState();
const transactions = state.transactions || [];
return h(
"div",
{ className: "card" },
h(
"h3",
{
style: {
textAlign: "left",
borderBottom: "1px solid var(--border)",
paddingBottom: "1rem",
marginBottom: "1rem",
},
},
"Recent Transactions",
),
transactions.length === 0
? h(
"p",
{ style: { color: "var(--text-muted)" } },
"No transactions yet",
)
: h(
"ul",
{ style: { listStyle: "none", padding: 0, margin: 0 } },
transactions.slice(0, 10).map((tx) => {
const isCredit = tx.type === "Deposit";
return h(
"li",
{
style: {
display: "flex",
justifyContent: "space-between",
padding: "0.75rem 0",
borderBottom:
"1px solid var(--bg-surface-hover)",
},
},
h(
"div",
{ style: { textAlign: "left" } },
h(
"div",
{ style: { fontWeight: "500" } },
tx.type,
),
h(
"div",
{
style: {
fontSize: "0.85rem",
color: "var(--text-muted)",
},
},
tx.date,
),
),
h(
"div",
{
style: {
fontWeight: "700",
color: isCredit ? "#10b981" : "#ef4444",
},
},
(isCredit ? "+" : "-") +
"$" +
Math.abs(tx.amount).toLocaleString(),
),
);
}),
),
);
}
function DepositWithdrawForm() {
const state = store.getState();
const bankBalance = state.accounts.bank;
const cashBalance = state.accounts.cash;
const getAmount = () => {
const input = document.getElementById("deposit-withdraw-amount");
return parseFloat(input?.value) || 0;
};
const clearInput = () => {
const input = document.getElementById("deposit-withdraw-amount");
if (input) input.value = "";
};
const handleDeposit = () => {
const amount = getAmount();
if (!amount || amount <= 0) {
console.log("Please enter a valid amount");
return;
}
if (amount > cashBalance) {
console.log("Insufficient cash");
return;
}
sendEvent("bank::deposit", { amount });
store.dispatch(deposit(amount));
clearInput();
};
const handleWithdraw = () => {
const amount = getAmount();
if (!amount || amount <= 0) {
console.log("Please enter a valid amount");
return;
}
if (amount > bankBalance) {
console.log("Insufficient funds");
return;
}
sendEvent("bank::withdraw", { amount });
store.dispatch(withdraw(amount));
clearInput();
};
return h(
"div",
{ className: "card" },
h("h2", null, "Deposit / Withdraw"),
h(
"div",
{ className: "balance-info" },
h(
"div",
{ className: "balance-info-item" },
h("span", { className: "balance-info-label" }, "Cash"),
h(
"span",
{ className: "balance-info-value cash" },
"$" + cashBalance.toLocaleString(),
),
),
h(
"div",
{ className: "balance-info-item" },
h("span", { className: "balance-info-label" }, "Bank"),
h(
"span",
{ className: "balance-info-value" },
"$" + bankBalance.toLocaleString(),
),
),
),
h(
"div",
{ className: "deposit-withdraw-form" },
h("input", {
id: "deposit-withdraw-amount",
type: "number",
placeholder: "Enter amount...",
min: "1",
}),
h(
"div",
{ className: "deposit-withdraw-buttons" },
h(
"button",
{ onClick: handleDeposit, disabled: cashBalance <= 0 },
"Deposit",
),
h(
"button",
{ onClick: handleWithdraw, disabled: bankBalance <= 0 },
"Withdraw",
),
),
),
);
}
function TransferForm() {
const state = store.getState();
const players = state.accounts.players || {};
const currentUid = state.uid;
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const amount = parseFloat(formData.get("amount"));
const playerId = formData.get("playerId");
if (!amount || amount <= 0) {
console.log("Please enter a valid amount");
return;
}
const currentState = store.getState();
if (!playerId) {
console.log("Please select a recipient");
return;
}
if (amount > currentState.accounts.bank) {
console.log("Insufficient funds");
return;
}
sendEvent("bank::transfer", { from: "bank", amount, target: playerId });
store.dispatch(transfer("bank", amount, "player"));
e.target.reset();
};
// Build player options
const playerOptions = [
h(
"option",
{ value: "", disabled: true, selected: true },
"Select player...",
),
];
Object.keys(players).forEach((uid) => {
if (uid !== currentUid && players[uid]?.name) {
playerOptions.push(h("option", { value: uid }, players[uid].name));
}
});
return h(
"div",
{ className: "card" },
h("h2", null, "Wire Transfer"),
h(
"form",
{ onSubmit: handleSubmit },
h(
"div",
null,
h("label", null, "Recipient"),
h("select", { name: "playerId" }, playerOptions),
),
h(
"div",
null,
h("label", null, "Amount"),
h("input", {
name: "amount",
type: "number",
placeholder: "0.00",
}),
),
h("button", { type: "submit" }, "Send Funds"),
),
);
}
function BankDashboard() {
const state = store.getState();
const bankBalance = state.accounts.bank;
const earnings = state.accounts.earnings;
return h(
"div",
{ className: "content" },
h(
"div",
{ className: "card", style: { gridColumn: "span 2" } },
h(
"h2",
{
style: {
fontSize: "1.2rem",
color: "var(--text-muted)",
textTransform: "uppercase",
letterSpacing: "0.05em",
},
},
"Account Balance",
),
h(
"div",
{
style: {
fontSize: "2.8rem",
fontWeight: "800",
color: "var(--primary-hover)",
margin: "1rem 0",
},
},
"$" + bankBalance.toLocaleString(),
),
h(
"div",
{
style: {
textAlign: "center",
color: "var(--text-muted)",
fontSize: "1.1rem",
marginBottom: "1rem",
},
},
"Pending: ",
h(
"span",
{ style: { color: "#fbbf24", fontWeight: "bold" } },
"$" + earnings.toLocaleString(),
),
),
h(
"div",
{ className: "deposit-earnings-button" },
h(
"button",
{
onClick: () => {
sendEvent("bank::depositEarnings", {
amount: earnings,
});
store.dispatch(depositEarnings(earnings));
},
disabled: earnings <= 0,
style: { width: "25%" },
},
"Deposit Earnings",
),
),
),
DepositWithdrawForm(),
TransferForm(),
h("div", { style: { gridColumn: "span 2" } }, TransactionHistory()),
);
}
function Footer() {
return h(
"div",
{ className: "footer" },
h(
"div",
{ className: "wrapper" },
h(
"div",
null,
h("h3", null, "Secure Banking"),
h(
"ul",
{ style: { listStyleType: "none", padding: 0 } },
h("li", null, "FDIC Insured"),
h("li", null, "Fraud Protection"),
h("li", null, "24/7 Support"),
h("li", null, "API Access"),
),
),
h(
"div",
null,
h("h3", null, "Notices"),
h(
"ul",
{ style: { listStyleType: "none", padding: 0 } },
h("li", null, "Terms of Service"),
h("li", null, "Privacy Policy"),
h("li", null, "Interest Rates"),
h("li", null, "Report Fraud"),
),
),
),
);
}
function App() {
return h(
"div",
{ className: "app-shell" },
WindowTitleBar(),
h(
"main",
null,
Navbar(),
h("div", { className: "container" }, BankDashboard()),
Footer(),
),
);
}
//=============================================================================
// #region ARMA 3 INTEGRATION
//=============================================================================
function sendEvent(event, data) {
if (typeof A3API !== "undefined") {
A3API.SendAlert(JSON.stringify({ event, data }));
} else {
console.log("Event:", event, "Data:", data);
}
}
//=============================================================================
// #region INITIALIZATION
//=============================================================================
let initialized = false;
function initBank() {
if (initialized) return;
const root = document.getElementById("app");
if (root) {
if (typeof store !== "undefined") {
store.subscribe(() => _render());
}
render(App, root);
initialized = true;
console.log("[Bank] Interface initialized");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initBank);
} else {
initBank();
}

View File

@ -0,0 +1,64 @@
<!-- Generated by tools/build-webui.mjs for bank UI index. Do not edit directly. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FORGE Banking Console</title>
<script>
window.ForgeSiteConfig = {
addonName: "bank",
logLabel: "Bank UI",
styles: ["bank-ui.css"],
commonScripts: ["forge-webui.js"],
scripts: ["bank-ui.js"],
};
(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(
"[Bank UI] Failed to load Forge site loader.",
error,
);
});
})();
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

View File

@ -1,305 +0,0 @@
/**
* Banking Application Store
* Redux-like state management for bank and ATM interfaces
*/
// ============================================================================
// REDUX CORE IMPLEMENTATION
// ============================================================================
/**
* Creates a Redux-like store.
* @param {Function} reducer - A function that returns the next state tree
* @returns {Object} The store object with methods: getState, dispatch, subscribe
*/
function createStore(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
// Initialize state
dispatch({});
return { getState, dispatch, subscribe };
}
// ============================================================================
// STATE
// ============================================================================
const initialState = {
uid: "",
accounts: {
bank: 0,
cash: 0,
earnings: 0,
org: 0,
},
pin: "1234",
transactions: [],
};
// ============================================================================
// ACTION TYPES
// ============================================================================
const DEPOSIT = "DEPOSIT";
const DEPOSIT_EARNINGS = "DEPOSIT_EARNINGS";
const WITHDRAW = "WITHDRAW";
const TRANSFER = "TRANSFER";
const UPDATE_ACCOUNTS = "UPDATE_ACCOUNTS";
const UPDATE_PIN = "UPDATE_PIN";
// ============================================================================
// ACTION CREATORS
// ============================================================================
const deposit = (amount) => ({
type: DEPOSIT,
payload: amount,
});
const depositEarnings = (amount) => ({
type: DEPOSIT_EARNINGS,
payload: amount,
});
const withdraw = (amount) => ({
type: WITHDRAW,
payload: amount,
});
const transfer = (from, amount, target) => ({
type: TRANSFER,
from: from,
payload: amount,
target: target,
});
const updateAccounts = (accounts) => ({
type: UPDATE_ACCOUNTS,
payload: accounts,
});
const updatePin = (pin) => ({
type: UPDATE_PIN,
payload: pin,
});
// ============================================================================
// REDUCER
// ============================================================================
function appReducer(state = initialState, action) {
switch (action.type) {
case DEPOSIT:
if (state.accounts.cash < action.payload) {
console.warn("Insufficient cash!");
return state;
}
return {
...state,
accounts: {
...state.accounts,
bank: state.accounts.bank + action.payload,
cash: state.accounts.cash - action.payload,
},
transactions: [
...state.transactions,
{
type: "Deposit",
amount: action.payload,
date: new Date().toLocaleString(),
},
],
};
case DEPOSIT_EARNINGS:
if (state.accounts.earnings < action.payload) {
console.warn("Insufficient earnings!");
return state;
}
return {
...state,
accounts: {
...state.accounts,
bank: state.accounts.bank + action.payload,
earnings: state.accounts.earnings - action.payload,
},
transactions: [
...state.transactions,
{
type: "Deposit Earnings",
amount: action.payload,
date: new Date().toLocaleString(),
},
],
};
case WITHDRAW:
if (state.accounts.bank < action.payload) {
console.warn("Insufficient funds!");
return state;
}
return {
...state,
accounts: {
...state.accounts,
bank: state.accounts.bank - action.payload,
cash: state.accounts.cash + action.payload,
},
transactions: [
...state.transactions,
{
type: "Withdraw",
amount: action.payload,
date: new Date().toLocaleString(),
},
],
};
case TRANSFER:
const fromAccount = action.from;
if (state.accounts[fromAccount] < action.payload) {
console.warn("Insufficient funds!");
return state;
}
const newAccounts = { ...state.accounts };
newAccounts[fromAccount] -= action.payload;
return {
...state,
accounts: newAccounts,
transactions: [
...state.transactions,
{
type: "Transfer",
amount: action.payload,
from: fromAccount,
target: action.target,
date: new Date().toLocaleString(),
},
],
};
case UPDATE_ACCOUNTS:
return {
...state,
accounts: {
...state.accounts,
...action.payload,
},
};
case UPDATE_PIN:
return {
...state,
pin: String(action.payload),
};
case "SET_UID":
return {
...state,
uid: action.payload,
};
default:
return state;
}
}
// ============================================================================
// STORE INSTANCE
// ============================================================================
const store = createStore(appReducer);
// ============================================================================
// ARMA 3 INTEGRATION
// ============================================================================
/**
* Sends an event to Arma 3
* @param {string} event - Event name
* @param {Object} data - Event data
*/
function sendEvent(event, data) {
if (typeof A3API !== "undefined") {
A3API.SendAlert(
JSON.stringify({
event: event,
data: data,
}),
);
} else {
console.log("Event:", event, "Data:", data);
}
}
/**
* Syncs account data from Arma 3 into the store
* @param {Object} data - Account data from Arma 3
*/
function syncDataFromArma(data) {
if (data && typeof data === "object") {
const accounts = {};
if (data.bank !== undefined) accounts.bank = data.bank;
if (data.cash !== undefined) accounts.cash = data.cash;
if (data.earnings !== undefined) accounts.earnings = data.earnings;
if (data.org !== undefined) accounts.org = data.org;
if (data.players !== undefined) accounts.players = data.players;
if (Object.keys(accounts).length > 0) {
store.dispatch(updateAccounts(accounts));
}
// Update UID if provided
if (data.uid !== undefined && data.uid !== store.getState().uid) {
store.dispatch({ type: "SET_UID", payload: data.uid });
}
// Update pin if provided
if (data.pin !== undefined) {
store.dispatch(updatePin(data.pin));
}
console.log(
"[Store] Synced data from Arma:",
store.getState().accounts,
);
} else {
console.warn("[Store] Invalid data received:", data);
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
// Request initial data from Arma on load
if (typeof A3API !== "undefined") {
// Delay request slightly to ensure everything is loaded
setTimeout(() => {
sendEvent("bank::sync", {});
}, 100);
}
// Expose sync function globally for Arma to call
if (typeof window !== "undefined") {
window.syncDataFromArma = syncDataFromArma;
}

View File

@ -0,0 +1,116 @@
(function () {
const ForgeWebUI = window.ForgeWebUI;
const BankApp = window.BankApp;
const islandDefinitions = [
{
id: "bank-notice-root",
preserveScroll: false,
render: () => BankApp.componentFns.NoticeLayer(),
},
{
id: "bank-sidebar-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankSidebar(),
},
{
id: "bank-page-header-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankPageHeader(),
},
{
id: "bank-summary-section-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankSummarySection(),
},
{
id: "bank-action-sections-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankActionSections(),
},
{
id: "bank-support-section-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankSupportSection(),
},
{
id: "bank-history-section-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankHistorySection(),
},
{
id: "bank-atm-root",
preserveScroll: false,
render: () => BankApp.componentFns.ATMView(),
},
{
id: "bank-footer-root",
preserveScroll: false,
render: () => BankApp.componentFns.BankFooter(),
},
];
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: "bank",
root: "#app",
setup({ root }) {
const islandManager = createIslandManager();
ForgeWebUI.mount(root, () => BankApp.components.App(), {
preserveScroll: false,
});
if (BankApp.bridge) {
BankApp.bridge.notifyReady();
}
ForgeWebUI.effect(() => {
BankApp.store.getMode();
requestAnimationFrame(() => {
islandManager.sync();
});
});
},
});
app.start();
})();

View File

@ -0,0 +1,51 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const store = BankApp.store;
const bridge = window.ForgeWebUI.createBridge({
closeEvent: "bank::close",
globalName: "ForgeBridge",
readyEvent: "bank::ready",
});
function hydrate(payloadData) {
BankApp.data.applyHydratePayload(payloadData);
store.hydrateFromPayload(payloadData);
}
bridge.on("bank::hydrate", hydrate);
bridge.on("bank::sync", hydrate);
bridge.on("bank::notice", (payloadData) => {
if (BankApp.actions) {
BankApp.actions.showNotice(
payloadData.type || "error",
payloadData.message || "Bank notice received.",
);
}
});
BankApp.bridge = {
notifyReady() {
return bridge.ready({ loaded: true });
},
receive: bridge.receive,
requestClose() {
return bridge.close({});
},
requestDeposit(payload) {
return bridge.send("bank::deposit::request", payload);
},
requestDepositEarnings(payload) {
return bridge.send("bank::depositEarnings::request", payload);
},
requestRefresh() {
return bridge.send("bank::refresh", {});
},
requestTransfer(payload) {
return bridge.send("bank::transfer::request", payload);
},
requestWithdraw(payload) {
return bridge.send("bank::withdraw::request", payload);
},
sendEvent: bridge.send,
};
})();

View File

@ -0,0 +1,104 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar;
const store = BankApp.store;
const actions = BankApp.actions;
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.NoticeLayer = function NoticeLayer() {
const notice = store.getNotice();
if (!notice.text) {
return null;
}
return h(
"div",
{ className: "bank-notice-stack" },
h(
"div",
{
className:
notice.type === "error"
? "bank-notice is-error"
: "bank-notice is-success",
},
notice.text,
),
);
};
BankApp.components = BankApp.components || {};
BankApp.components.App = function App() {
const mode = store.getMode();
return h(
"div",
{ className: mode === "atm" ? "bank-shell is-atm" : "bank-shell" },
mode === "atm"
? null
: WindowTitleBar({
kicker: "FORGE Finance",
title: "Global Banking Network",
onClose: () => actions.closeBank(),
closeLabel: "Close banking interface",
}),
h("div", { id: "bank-notice-root" }),
mode === "atm"
? h("div", { id: "bank-atm-root" })
: [
h(
"div",
{
className: "bank-scroll-shell",
"data-preserve-scroll-id": "bank-page-scroll",
},
[
h(
"div",
{ className: "bank-layout" },
h("div", { id: "bank-sidebar-root" }),
h(
"main",
{ className: "bank-main" },
h(
"div",
{ className: "bank-page" },
h("div", {
id: "bank-page-header-root",
}),
h(
"p",
{ className: "bank-page-copy" },
"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console.",
),
h("div", {
className: "bank-page-divider",
}),
h(
"div",
{ className: "bank-page-body" },
h("div", {
id: "bank-summary-section-root",
}),
h("div", {
id: "bank-action-sections-root",
}),
h("div", {
id: "bank-support-section-root",
}),
h("div", {
id: "bank-history-section-root",
}),
),
),
),
),
h("div", { id: "bank-footer-root" }),
],
),
],
);
};
})();

View File

@ -0,0 +1,91 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const actions = BankApp.actions;
const { account, session } = BankApp.data;
const { formatCurrency, statCard } = BankApp.componentFns;
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.BankSidebar = function BankSidebar() {
store.getAccountVersion();
store.getSessionVersion();
return h(
"aside",
{ className: "bank-sidebar" },
h(
"section",
{ className: "bank-module" },
h(
"div",
{ className: "bank-module-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Account"),
h(
"h2",
{ className: "bank-section-title" },
"Balances",
),
),
h("span", { className: "bank-pill" }, "Live"),
),
h(
"div",
{ className: "bank-summary-grid" },
statCard("Bank", formatCurrency(account.bank), "accent"),
statCard("Cash", formatCurrency(account.cash)),
statCard(
"Earnings",
formatCurrency(account.earnings),
account.earnings > 0 ? "warning" : "",
),
statCard(
"Org Funds",
formatCurrency(session.orgFunds),
session.orgFunds > 0 ? "success" : "",
),
),
),
h(
"section",
{ className: "bank-module" },
h(
"div",
{ className: "bank-module-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Profile"),
h(
"h2",
{ className: "bank-section-title" },
"Account Holder",
),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.refreshBank(),
},
"Refresh",
),
),
h(
"div",
{ className: "bank-profile-stack" },
statCard("Name", session.playerName || "Unknown"),
statCard("UID", session.uid || "-"),
statCard(
"Organization",
session.orgName || "No active organization",
),
),
),
);
};
})();

View File

@ -0,0 +1,72 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const { account, session } = BankApp.data;
const { formatCurrency } = BankApp.componentFns;
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.BankFooter = function BankFooter() {
store.getAccountVersion();
store.getSessionVersion();
const sections = [
{
title: "Banking Resources",
items: [
"Account Access Policy",
"Transfer & Wire Guidelines",
"Cash Handling Schedule",
"Terminal Security Notice",
],
},
{
title: "Bank Support",
items: session.orgName
? [
`Organization: ${session.orgName}`,
`Treasury Reference: ${formatCurrency(session.orgFunds)}`,
`${session.transferTargets.length} transfer recipient(s) currently visible.`,
`Primary Ledger: ${formatCurrency(account.bank)}`,
]
: [
"Organization: No active treasury link",
`${session.transferTargets.length} transfer recipient(s) currently visible.`,
`Primary Ledger: ${formatCurrency(account.bank)}`,
`Cash On Hand: ${formatCurrency(account.cash)}`,
],
},
];
return h(
"footer",
{ className: "bank-footer-bar" },
h(
"div",
{ className: "bank-footer" },
...sections.map((section) =>
h(
"div",
{ className: "bank-footer-block" },
h(
"h3",
{ className: "bank-footer-title" },
section.title,
),
h(
"ul",
{ className: "bank-footer-list" },
...(section.items || []).map((item) =>
h(
"li",
{ className: "bank-footer-copy" },
item,
),
),
),
),
),
),
);
};
})();

View File

@ -0,0 +1,189 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const { account } = BankApp.data;
function formatCurrency(value) {
return `$${Math.round(Number(value || 0)).toLocaleString()}`;
}
function pending(actionName) {
return store.getPendingAction() === actionName;
}
function statCard(label, value, tone = "") {
return h(
"div",
{
className: tone
? `bank-stat-card is-${tone}`
: "bank-stat-card",
},
h("span", { className: "bank-stat-label" }, label),
h("span", { className: "bank-stat-value" }, value),
);
}
function metricCard(label, value, copy, tone = "") {
return h(
"div",
{
className: tone
? `bank-metric-card is-${tone}`
: "bank-metric-card",
},
h("span", { className: "bank-eyebrow" }, label),
h("span", { className: "bank-metric-value" }, value),
h("span", { className: "bank-metric-copy" }, copy),
);
}
function pinIndicators(value) {
const pin = String(value || "");
return h(
"div",
{ className: "bank-pin-indicators" },
[0, 1, 2, 3].map((index) =>
h("span", {
className:
index < pin.length
? "bank-pin-indicator is-filled"
: "bank-pin-indicator",
}),
),
);
}
function readInputValue(id) {
return document.getElementById(id)?.value || "";
}
function clearInputValue(id) {
const input = document.getElementById(id);
if (input) {
input.value = "";
}
}
function keypad(onDigit, onBackspace, onClear, onEnter) {
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
return h(
"div",
{ className: "bank-keypad" },
keys.map((digit) =>
h(
"button",
{
type: "button",
className: "bank-key",
onClick: () => onDigit(digit),
},
digit,
),
),
h(
"button",
{
type: "button",
className: "bank-key is-muted",
onClick: onClear,
},
"C",
),
h(
"button",
{
type: "button",
className: "bank-key",
onClick: () => onDigit("0"),
},
"0",
),
h(
"button",
{
type: "button",
className: "bank-key is-accent",
onClick: onEnter,
},
"Enter",
),
h(
"button",
{
type: "button",
className: "bank-key is-wide",
onClick: onBackspace,
},
"Backspace",
),
);
}
function transactionRows() {
const transactions = Array.isArray(account.transactions)
? account.transactions
: [];
if (transactions.length === 0) {
return h(
"div",
{ className: "bank-empty-state" },
h("h3", { className: "bank-empty-title" }, "No transactions"),
h(
"p",
{ className: "bank-empty-copy" },
"Deposits, withdrawals, and transfers will appear here after the account begins moving funds.",
),
);
}
return h(
"div",
{ className: "bank-history-list" },
transactions
.slice(0, 8)
.map((entry) =>
h(
"div",
{ className: "bank-history-row" },
h(
"div",
{ className: "bank-history-copy" },
h(
"span",
{ className: "bank-history-title" },
entry.type || "Transaction",
),
h(
"span",
{ className: "bank-history-meta" },
entry.date || "Pending timestamp",
),
),
h(
"span",
{ className: "bank-history-value" },
formatCurrency(entry.amount || 0),
),
),
),
);
}
BankApp.componentFns = BankApp.componentFns || {};
Object.assign(BankApp.componentFns, {
clearInputValue,
formatCurrency,
keypad,
metricCard,
pending,
pinIndicators,
readInputValue,
statCard,
transactionRows,
});
})();

View File

@ -0,0 +1,44 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const defaultSession = {
mode: "bank",
orgFunds: 0,
orgName: "",
playerName: "",
transferTargets: [],
uid: "",
};
const defaultAccount = {
bank: 0,
cash: 0,
earnings: 0,
pin: "1234",
transactions: [],
};
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));
}
BankApp.data = {
account: Object.assign({}, defaultAccount),
session: Object.assign({}, defaultSession),
applyHydratePayload(payload) {
replaceObject(
this.session,
Object.assign({}, defaultSession, payload?.session || {}),
);
replaceObject(
this.account,
Object.assign({}, defaultAccount, payload?.account || {}),
);
},
};
})();

View File

@ -0,0 +1,238 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const actions = BankApp.actions;
const { account } = BankApp.data;
const { formatCurrency, keypad, pinIndicators } = BankApp.componentFns;
function atmMenuCard() {
return h(
"div",
{ className: "bank-atm-action-grid" },
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.selectAtmView("withdraw"),
},
"Withdraw Cash",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.selectAtmView("deposit"),
},
"Deposit Cash",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.selectAtmView("balance"),
},
"Check Balance",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.closeBank(),
},
"Exit Terminal",
),
);
}
function atmAmountMenu(kind) {
const label = kind === "deposit" ? "Deposit" : "Withdraw";
const amounts = [20, 50, 100, 500];
return h(
"div",
{ className: "bank-atm-action-grid" },
amounts.map((amount) =>
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.requestAtmAmount(kind, amount),
},
`${label} ${formatCurrency(amount)}`,
),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () =>
actions.selectAtmView(
kind === "deposit"
? "customDeposit"
: "customWithdraw",
),
},
"Custom Amount",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.selectAtmView("menu"),
},
"Back",
),
);
}
function atmCustomAmount(kind) {
const label = kind === "deposit" ? "Deposit" : "Withdraw";
return h(
"div",
{ className: "bank-atm-stack" },
h(
"div",
{ className: "bank-pin-display" },
store.getCustomAmount()
? formatCurrency(store.getCustomAmount())
: "$0",
),
keypad(
actions.appendCustomAmountDigit,
actions.backspaceCustomAmount,
actions.clearCustomAmount,
() => actions.submitCustomAmount(kind),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.selectAtmView("menu"),
},
`Cancel ${label}`,
),
);
}
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.ATMView = function ATMView() {
store.getAccountVersion();
const atmViewName = store.getAtmView();
const enteredPin = String(store.getEnteredPin() || "");
let title = "Terminal Access";
let copy =
"Authenticate with the four-digit account PIN before using the terminal.";
let content = null;
switch (atmViewName) {
case "menu":
title = "ATM Menu";
copy =
"Select a banking action. The ATM can deposit, withdraw, and show the live account balance.";
content = atmMenuCard();
break;
case "withdraw":
title = "Withdraw Cash";
copy =
"Choose a preset amount or enter a custom amount for withdrawal.";
content = atmAmountMenu("withdraw");
break;
case "deposit":
title = "Deposit Cash";
copy =
"Move cash on hand back into the main bank balance from the terminal.";
content = atmAmountMenu("deposit");
break;
case "customWithdraw":
title = "Custom Withdraw";
copy = "Enter the exact withdrawal amount.";
content = atmCustomAmount("withdraw");
break;
case "customDeposit":
title = "Custom Deposit";
copy = "Enter the exact deposit amount.";
content = atmCustomAmount("deposit");
break;
case "balance":
title = "Available Balance";
copy = "Current bank balance available at this terminal.";
content = h(
"div",
{ className: "bank-atm-stack" },
h(
"div",
{ className: "bank-balance-display" },
formatCurrency(account.bank),
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
onClick: () => actions.selectAtmView("menu"),
},
"Return to Menu",
),
);
break;
default:
content = h(
"div",
{ className: "bank-atm-stack" },
h(
"div",
{ className: "bank-pin-display" },
pinIndicators(enteredPin),
),
keypad(
actions.appendPinDigit,
actions.backspacePin,
actions.clearPin,
actions.submitPin,
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
onClick: () => actions.closeBank(),
},
"Exit Terminal",
),
);
break;
}
return h(
"div",
{ className: "bank-atm-shell" },
h(
"section",
{ className: "bank-atm-panel" },
h(
"div",
{ className: "bank-panel-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "ATM"),
h("h1", { className: "bank-title" }, title),
),
h("span", { className: "bank-pill" }, "Secure Terminal"),
),
h("p", { className: "bank-panel-copy" }, copy),
content,
),
);
};
})();

View File

@ -0,0 +1,321 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { h } = BankApp.runtime;
const store = BankApp.store;
const actions = BankApp.actions;
const { account, session } = BankApp.data;
const {
clearInputValue,
formatCurrency,
metricCard,
pending,
readInputValue,
transactionRows,
} = BankApp.componentFns;
function trackAccount() {
store.getAccountVersion();
}
function trackSession() {
store.getSessionVersion();
}
function pageHeader() {
trackSession();
return h(
"div",
{ className: "bank-page-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Treasury Desk"),
h("h1", { className: "bank-title" }, "Personal Banking"),
),
h(
"span",
{ className: "bank-pill" },
session.playerName || "Account Holder",
),
);
}
function summarySection() {
trackAccount();
trackSession();
return h(
"section",
{ className: "bank-page-section bank-summary-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Overview"),
h(
"h2",
{ className: "bank-section-title" },
"Financial Position",
),
),
h("span", { className: "bank-pill" }, "Banking Desk"),
),
h(
"div",
{ className: "bank-summary-band" },
metricCard(
"Primary Balance",
formatCurrency(account.bank),
"Available for transfers and withdrawals.",
"accent",
),
metricCard(
"Cash On Hand",
formatCurrency(account.cash),
"Funds currently carried by the player.",
),
metricCard(
"Pending Earnings",
formatCurrency(account.earnings),
"Ready to sweep into the main account ledger.",
account.earnings > 0 ? "warning" : "",
),
metricCard(
"Org Snapshot",
formatCurrency(session.orgFunds),
"Reference value pulled from the organization treasury.",
session.orgFunds > 0 ? "success" : "",
),
),
);
}
function actionSections() {
trackSession();
return h(
"div",
{ className: "bank-action-sections" },
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Movement"),
h(
"h2",
{ className: "bank-section-title" },
"Deposit / Withdraw",
),
),
),
h(
"div",
{ className: "bank-form-stack" },
h("input", {
id: "bank-amount-input",
className: "bank-input",
type: "number",
min: "1",
placeholder: "Enter amount",
}),
h(
"div",
{ className: "bank-action-row" },
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled: pending("deposit"),
onClick: () => {
const sent = actions.requestDeposit(
readInputValue("bank-amount-input"),
);
if (sent) {
clearInputValue("bank-amount-input");
}
},
},
pending("deposit") ? "Depositing..." : "Deposit",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-secondary",
disabled: pending("withdraw"),
onClick: () => {
const sent = actions.requestWithdraw(
readInputValue("bank-amount-input"),
);
if (sent) {
clearInputValue("bank-amount-input");
}
},
},
pending("withdraw") ? "Withdrawing..." : "Withdraw",
),
),
),
),
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Transfer"),
h(
"h2",
{ className: "bank-section-title" },
"Wire Funds",
),
),
),
h(
"div",
{ className: "bank-form-stack" },
h(
"select",
{
id: "bank-transfer-target",
className: "bank-select",
},
h(
"option",
{ value: "" },
session.transferTargets.length > 0
? "Select recipient"
: "No available recipients",
),
session.transferTargets.map((entry) =>
h(
"option",
{ value: entry.uid },
entry.name || entry.uid,
),
),
),
h("input", {
id: "bank-transfer-amount",
className: "bank-input",
type: "number",
min: "1",
placeholder: "Enter transfer amount",
}),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled:
pending("transfer") ||
session.transferTargets.length === 0,
onClick: () => {
const sent = actions.requestTransfer(
readInputValue("bank-transfer-target"),
readInputValue("bank-transfer-amount"),
);
if (sent) {
clearInputValue("bank-transfer-amount");
}
},
},
pending("transfer")
? "Transferring..."
: "Transfer Funds",
),
),
),
);
}
function supportSection() {
trackAccount();
return h(
"div",
{ className: "bank-support-sections" },
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Sweep"),
h(
"h2",
{ className: "bank-section-title" },
"Deposit Earnings",
),
),
),
h(
"p",
{ className: "bank-card-copy" },
"Sweep pending earnings into the primary account when you want them reflected in the main balance.",
),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled:
pending("depositearnings") ||
Number(account.earnings || 0) <= 0,
onClick: () =>
actions.requestDepositEarnings(account.earnings),
},
pending("depositearnings")
? "Depositing..."
: "Deposit Earnings",
),
),
);
}
function historySection() {
trackAccount();
return h(
"section",
{ className: "bank-page-section bank-history-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "History"),
h(
"h2",
{ className: "bank-section-title" },
"Recent Transactions",
),
),
),
transactionRows(),
);
}
BankApp.componentFns = BankApp.componentFns || {};
BankApp.componentFns.BankPageHeader = pageHeader;
BankApp.componentFns.BankSummarySection = summarySection;
BankApp.componentFns.BankActionSections = actionSections;
BankApp.componentFns.BankSupportSection = supportSection;
BankApp.componentFns.BankHistorySection = historySection;
})();

View File

@ -0,0 +1,343 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const store = BankApp.store;
let noticeTimer = null;
function getAccount() {
return BankApp.data?.account || {};
}
function getSession() {
return BankApp.data?.session || {};
}
function normalizeAmount(value) {
const amount = Math.floor(Number(value || 0));
return Number.isFinite(amount) ? amount : 0;
}
function showNotice(type, text) {
store.setNotice({ type, text });
if (noticeTimer) {
clearTimeout(noticeTimer);
}
noticeTimer = setTimeout(() => {
store.setNotice({ text: "", type: "" });
noticeTimer = null;
}, 3200);
}
function closeBank() {
const bridge = BankApp.bridge;
if (bridge && typeof bridge.requestClose === "function") {
const sent = bridge.requestClose();
if (sent) {
return true;
}
}
showNotice("error", "Bank bridge is unavailable.");
return false;
}
function refreshBank() {
const bridge = BankApp.bridge;
if (bridge && typeof bridge.requestRefresh === "function") {
const sent = bridge.requestRefresh();
if (sent) {
return true;
}
}
showNotice("error", "Bank refresh bridge is unavailable.");
return false;
}
function requestDeposit(amountValue) {
const amount = normalizeAmount(amountValue);
const account = getAccount();
if (amount <= 0) {
showNotice("error", "Enter a valid deposit amount.");
return false;
}
if (amount > Number(account.cash || 0)) {
showNotice("error", "Cash on hand cannot cover that deposit.");
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestDeposit !== "function") {
showNotice("error", "Deposit bridge is unavailable.");
return false;
}
store.startAction("deposit");
const sent = bridge.requestDeposit({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Deposit bridge is unavailable.");
return false;
}
return true;
}
function requestWithdraw(amountValue) {
const amount = normalizeAmount(amountValue);
const account = getAccount();
if (amount <= 0) {
showNotice("error", "Enter a valid withdrawal amount.");
return false;
}
if (amount > Number(account.bank || 0)) {
showNotice("error", "Bank balance cannot cover that withdrawal.");
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestWithdraw !== "function") {
showNotice("error", "Withdraw bridge is unavailable.");
return false;
}
store.startAction("withdraw");
const sent = bridge.requestWithdraw({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Withdraw bridge is unavailable.");
return false;
}
return true;
}
function requestTransfer(targetUid, amountValue) {
const amount = normalizeAmount(amountValue);
const session = getSession();
const account = getAccount();
const targetId = String(targetUid || "").trim();
if (!targetId) {
showNotice("error", "Select a transfer recipient.");
return false;
}
if (targetId === String(session.uid || "")) {
showNotice("error", "You cannot transfer funds to yourself.");
return false;
}
if (amount <= 0) {
showNotice("error", "Enter a valid transfer amount.");
return false;
}
if (amount > Number(account.bank || 0)) {
showNotice("error", "Bank balance cannot cover that transfer.");
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestTransfer !== "function") {
showNotice("error", "Transfer bridge is unavailable.");
return false;
}
store.startAction("transfer");
const sent = bridge.requestTransfer({
amount,
from: "bank",
target: targetId,
});
if (!sent) {
store.finishAction();
showNotice("error", "Transfer bridge is unavailable.");
return false;
}
return true;
}
function requestDepositEarnings(amountValue) {
const amount = normalizeAmount(amountValue);
const account = getAccount();
if (amount <= 0) {
showNotice("error", "No earnings are available to deposit.");
return false;
}
if (amount > Number(account.earnings || 0)) {
showNotice(
"error",
"Pending earnings cannot cover that deposit request.",
);
return false;
}
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestDepositEarnings !== "function") {
showNotice("error", "Earnings bridge is unavailable.");
return false;
}
store.startAction("depositearnings");
const sent = bridge.requestDepositEarnings({ amount });
if (!sent) {
store.finishAction();
showNotice("error", "Earnings bridge is unavailable.");
return false;
}
return true;
}
function appendPinDigit(digit) {
const nextDigit = String(digit || "").trim();
if (!nextDigit) {
return;
}
const currentPin = String(store.getEnteredPin() || "");
if (currentPin.length >= 4) {
return;
}
store.setEnteredPin(currentPin + nextDigit);
}
function backspacePin() {
const currentPin = String(store.getEnteredPin() || "");
store.setEnteredPin(currentPin.slice(0, -1));
}
function clearPin() {
store.setEnteredPin("");
}
function submitPin() {
const enteredPin = String(store.getEnteredPin() || "");
const actualPin = String(getAccount().pin || "1234");
if (enteredPin.length !== 4) {
showNotice("error", "Enter your four-digit access PIN.");
return false;
}
if (enteredPin !== actualPin) {
clearPin();
showNotice("error", "Incorrect PIN.");
return false;
}
clearPin();
store.setAtmView("menu");
return true;
}
function selectAtmView(view) {
const nextView = String(view || "").trim();
if (!nextView) {
return false;
}
if (nextView === "pin") {
store.resetAtm();
return true;
}
store.setCustomAmount("");
store.setAtmView(nextView);
return true;
}
function appendCustomAmountDigit(digit) {
const nextDigit = String(digit || "").trim();
if (!nextDigit) {
return;
}
const currentValue = String(store.getCustomAmount() || "");
if (currentValue.length >= 7) {
return;
}
store.setCustomAmount(currentValue + nextDigit);
}
function backspaceCustomAmount() {
const currentValue = String(store.getCustomAmount() || "");
store.setCustomAmount(currentValue.slice(0, -1));
}
function clearCustomAmount() {
store.setCustomAmount("");
}
function submitCustomAmount(kind) {
const amount = normalizeAmount(store.getCustomAmount());
const nextKind = String(kind || "")
.trim()
.toLowerCase();
if (amount <= 0) {
showNotice("error", "Enter a valid transaction amount.");
return false;
}
const success =
nextKind === "deposit"
? requestDeposit(amount)
: requestWithdraw(amount);
if (success) {
store.setCustomAmount("");
store.setAtmView("menu");
}
return success;
}
function requestAtmAmount(kind, amount) {
const nextKind = String(kind || "")
.trim()
.toLowerCase();
const success =
nextKind === "deposit"
? requestDeposit(amount)
: requestWithdraw(amount);
if (success) {
store.setAtmView("menu");
}
return success;
}
BankApp.actions = {
appendCustomAmountDigit,
appendPinDigit,
backspaceCustomAmount,
backspacePin,
clearCustomAmount,
clearPin,
closeBank,
refreshBank,
requestAtmAmount,
requestDeposit,
requestDepositEarnings,
requestTransfer,
requestWithdraw,
selectAtmView,
showNotice,
submitCustomAmount,
submitPin,
};
})();

View File

@ -0,0 +1,63 @@
(function () {
const BankApp = (window.BankApp = window.BankApp || {});
const { createSignal } = BankApp.runtime;
class BankStore {
constructor() {
[this.getMode, this.setMode] = createSignal("bank");
[this.getNotice, this.setNotice] = createSignal({
text: "",
type: "",
});
[this.getPendingAction, this.setPendingAction] = createSignal("");
[this.getAtmView, this.setAtmView] = createSignal("pin");
[this.getEnteredPin, this.setEnteredPin] = createSignal("");
[this.getCustomAmount, this.setCustomAmount] = createSignal("");
[this.getAccountVersion, this.setAccountVersion] = createSignal(0);
[this.getSessionVersion, this.setSessionVersion] = createSignal(0);
}
finishAction() {
this.setPendingAction("");
}
hydrateFromPayload(payload) {
const mode = String(payload?.session?.mode || "bank")
.trim()
.toLowerCase();
const currentMode = this.getMode();
const currentAtmView = this.getAtmView();
this.setMode(mode === "atm" ? "atm" : "bank");
this.setPendingAction("");
this.setNotice({ text: "", type: "" });
this.setEnteredPin("");
this.setCustomAmount("");
this.setAccountVersion(this.getAccountVersion() + 1);
this.setSessionVersion(this.getSessionVersion() + 1);
if (mode === "atm") {
this.setAtmView(currentMode === "atm" ? currentAtmView : "pin");
return;
}
this.setAtmView("dashboard");
}
resetAtm() {
this.setEnteredPin("");
this.setCustomAmount("");
this.setAtmView("pin");
}
startAction(action) {
this.setPendingAction(
String(action || "")
.trim()
.toLowerCase(),
);
}
}
BankApp.store = new BankStore();
})();

View File

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

View File

@ -0,0 +1,590 @@
:root {
--bank-shell-bg: #f6f4ee;
--bank-surface: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
--bank-border: rgba(18, 54, 93, 0.12);
--bank-border-strong: rgba(18, 54, 93, 0.18);
--bank-text-main: #142f52;
--bank-text-muted: #6f86a3;
--bank-text-subtle: #8ea2bb;
--bank-accent: #275a8c;
--bank-accent-soft: #dfeaf9;
--bank-accent-line: rgba(39, 90, 140, 0.12);
--bank-shadow: 0 16px 30px rgba(18, 36, 57, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background: transparent;
color: var(--bank-text-main);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
button,
input,
select {
font: inherit;
}
.bank-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--bank-shell-bg);
}
.bank-scroll-shell {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.bank-layout {
min-height: 100%;
width: min(100%, 1600px);
margin: 0 auto;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 1.25rem;
padding: 1.25rem;
flex: 1 0 auto;
}
.bank-sidebar,
.bank-main {
min-height: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bank-main {
overflow: visible;
}
.bank-module,
.bank-card,
.bank-atm-panel {
background: var(--bank-surface);
border: 1px solid var(--bank-border);
border-radius: 1.3rem;
box-shadow: var(--bank-shadow);
}
.bank-module,
.bank-card,
.bank-atm-panel {
padding: 1rem;
display: flex;
flex-direction: column;
}
.bank-module-header,
.bank-card-header,
.bank-section-header,
.bank-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.bank-module-header,
.bank-card-header {
margin-bottom: 0.9rem;
}
.bank-page {
display: grid;
gap: 1.35rem;
padding: 0.1rem 0 0;
}
.bank-page-header {
padding-top: 0.4rem;
}
.bank-page-copy {
margin: 0;
color: var(--bank-text-muted);
line-height: 1.5;
max-width: 48rem;
}
.bank-page-divider {
border-top: 1px solid var(--bank-accent-line);
}
.bank-page-body {
display: grid;
gap: 1.25rem;
padding-bottom: 1.25rem;
}
.bank-page-section {
display: grid;
gap: 1rem;
padding: 1.15rem 1.2rem 1.25rem;
border: 1px solid var(--bank-border);
border-radius: 1.3rem;
background: rgba(255, 255, 255, 0.72);
box-shadow: none;
}
.bank-title,
.bank-section-title {
margin: 0;
color: var(--bank-text-main);
letter-spacing: -0.02em;
}
.bank-title {
font-size: 1.7rem;
}
.bank-section-title {
font-size: 1.1rem;
}
.bank-eyebrow,
.bank-footer-title,
.bank-stat-label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
color: var(--bank-text-subtle);
}
.bank-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.48rem 0.8rem;
border-radius: 999px;
background: var(--bank-accent-soft);
color: var(--bank-accent);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
}
.bank-summary-grid,
.bank-profile-stack {
display: grid;
gap: 0.8rem;
}
.bank-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bank-stat-card,
.bank-metric-card {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.9rem;
border-radius: 0.95rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.6);
}
.bank-stat-card.is-accent,
.bank-metric-card.is-accent {
background: linear-gradient(180deg, #edf4fe 0%, #dfeaf9 100%);
}
.bank-stat-card.is-success,
.bank-metric-card.is-success {
background: linear-gradient(180deg, #edf9f4 0%, #dff4ea 100%);
}
.bank-stat-card.is-warning,
.bank-metric-card.is-warning {
background: linear-gradient(180deg, #fdf7ea 0%, #f7edd4 100%);
}
.bank-stat-value,
.bank-metric-value {
min-width: 0;
color: var(--bank-text-main);
font-weight: 700;
overflow-wrap: anywhere;
}
.bank-stat-value {
font-size: 1rem;
}
.bank-metric-value {
font-size: 1.8rem;
letter-spacing: -0.03em;
}
.bank-metric-copy,
.bank-card-copy,
.bank-empty-copy,
.bank-footer-copy,
.bank-history-meta {
color: var(--bank-text-muted);
line-height: 1.45;
}
.bank-card-copy {
margin: 0 0 0.9rem;
}
.bank-summary-band {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.bank-action-sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.bank-support-sections {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
}
.bank-form-stack {
display: grid;
gap: 0.75rem;
}
.bank-input,
.bank-select {
width: 100%;
min-width: 0;
height: 2.9rem;
padding: 0 0.95rem;
border-radius: 0.8rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.82);
color: var(--bank-text-main);
}
.bank-action-row {
display: flex;
gap: 0.75rem;
}
.bank-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.85rem;
padding: 0.75rem 1rem;
border-radius: 0.8rem;
border: 1px solid var(--bank-border);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease,
border-color 160ms ease;
}
.bank-btn:disabled {
opacity: 0.55;
cursor: default;
}
.bank-btn-primary {
background: #455a77;
border-color: #455a77;
color: #fff;
}
.bank-btn-primary:hover:not(:disabled) {
background: #354863;
border-color: #354863;
}
.bank-btn-secondary {
background: rgba(255, 255, 255, 0.82);
color: var(--bank-accent);
}
.bank-btn-secondary:hover:not(:disabled) {
background: #eef4fd;
}
.bank-history-list {
display: grid;
gap: 0.75rem;
}
.bank-history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.95rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.6);
}
.bank-history-copy {
min-width: 0;
display: grid;
gap: 0.18rem;
}
.bank-history-title,
.bank-empty-title {
color: var(--bank-text-main);
font-weight: 700;
}
.bank-history-value {
white-space: nowrap;
font-weight: 700;
color: var(--bank-accent);
}
.bank-empty-state {
display: grid;
gap: 0.35rem;
padding: 1rem 0;
}
.bank-notice-stack {
position: fixed;
top: 1.2rem;
right: 1.5rem;
z-index: 12;
display: grid;
gap: 0.65rem;
}
.bank-notice {
max-width: 24rem;
padding: 0.85rem 1rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: #fff;
box-shadow: 0 14px 28px rgba(16, 34, 56, 0.14);
font-size: 0.92rem;
}
.bank-notice.is-success {
background: #ecfdf5;
border-color: #bbf7d0;
color: #166534;
}
.bank-notice.is-error {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.bank-footer-bar {
width: 100%;
margin-top: auto;
background: #1e293b;
color: #f8fafc;
}
.bank-footer {
width: min(100%, 1600px);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4rem;
padding: 3rem 1.25rem;
}
.bank-footer-block {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bank-footer-title {
margin: 0;
color: #f8fafc;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
padding-bottom: 0.5rem;
border-bottom: 1px solid #475569;
}
.bank-footer-list {
margin: 0;
padding: 0;
list-style: none;
}
.bank-atm-shell {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.bank-atm-panel {
width: min(100%, 560px);
display: grid;
gap: 1rem;
}
.bank-atm-stack {
display: grid;
gap: 1rem;
}
.bank-pin-display,
.bank-balance-display {
display: flex;
align-items: center;
justify-content: center;
min-height: 5rem;
padding: 1rem;
border-radius: 1rem;
border: 1px solid var(--bank-border-strong);
background: rgba(255, 255, 255, 0.68);
color: var(--bank-text-main);
text-align: center;
}
.bank-pin-display {
font-size: 2rem;
}
.bank-balance-display {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.bank-pin-indicators {
display: flex;
align-items: center;
justify-content: center;
gap: 0.9rem;
}
.bank-pin-indicator {
width: 1rem;
height: 1rem;
border-radius: 999px;
border: 2px solid var(--bank-accent);
background: transparent;
}
.bank-pin-indicator.is-filled {
background: var(--bank-accent);
}
.bank-keypad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.bank-key {
min-height: 3.2rem;
padding: 0.9rem;
border-radius: 0.9rem;
border: 1px solid var(--bank-border);
background: rgba(255, 255, 255, 0.82);
color: var(--bank-text-main);
font-weight: 700;
}
.bank-key.is-muted {
background: #eef2f8;
color: var(--bank-text-muted);
}
.bank-key.is-accent {
background: #455a77;
border-color: #455a77;
color: #fff;
}
.bank-key.is-wide {
grid-column: span 3;
}
.bank-atm-action-grid {
display: grid;
gap: 0.75rem;
}
.bank-shell.is-atm {
background: transparent;
min-height: 100%;
justify-content: center;
}
.bank-shell.is-atm .bank-atm-shell {
flex: 1;
width: 100%;
min-height: 100%;
max-width: 100%;
}
.bank-footer-copy {
color: #cbd5e1;
line-height: 1.5;
margin: 0 0 0.75rem;
}
@media (max-width: 1200px) {
.bank-layout {
grid-template-columns: 1fr;
}
.bank-main {
overflow: visible;
}
}
@media (max-width: 900px) {
.bank-summary-band,
.bank-action-sections,
.bank-footer {
grid-template-columns: 1fr;
}
.bank-summary-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,38 @@
export default {
addonName: "bank",
title: "FORGE Banking Console",
logLabel: "Bank UI",
outputDir: "_site",
jsBundles: [
{
name: "Bank UI app",
output: "bank-ui.js",
sources: [
"src/runtime.js",
"src/data.js",
"src/registry/store.js",
"src/bridge.js",
"src/registry/events.js",
"src/components/common.js",
"src/components/BankSidebar.js",
"src/components/Footer.js",
"src/pages/BankView.js",
"src/pages/ATMView.js",
"src/components/AppShell.js",
"src/bootstrap.js",
],
},
],
cssBundles: [
{
name: "Bank UI styles",
output: "bank-ui.css",
sources: ["src/styles.css"],
},
],
site: {
styles: ["bank-ui.css"],
commonScripts: ["forge-webui.js"],
scripts: ["bank-ui.js"],
},
};

View File

@ -218,10 +218,16 @@ button:disabled {
gap: 0.65rem;
}
.garage-footer-bar {
width: 100%;
border-top: 1px solid rgb(18 54 93 / 0.1);
}
.garage-footer {
width: min(100%, 1613px);
margin: 0 auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0.95rem 1.25rem 1.15rem;
border-top: 1px solid rgb(18 54 93 / 0.1);
}
.garage-meter-stack {

View File

@ -1220,49 +1220,53 @@
),
h(
"footer",
{ className: "garage-footer" },
{ className: "garage-footer-bar" },
h(
"div",
{ className: "garage-footer-block" },
{ className: "garage-footer" },
h(
"span",
{ className: "garage-footer-title" },
"Storage Capacity",
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Storage Capacity",
),
h(
"span",
{ className: "garage-footer-copy" },
`${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`,
),
),
h(
"span",
{ className: "garage-footer-copy" },
`${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`,
),
),
h(
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Retrieval Window",
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Retrieval Window",
),
h(
"span",
{ className: "garage-footer-copy" },
session.spawnBlocked
? "Spawn lane is blocked. Clear the bay before retrieving another vehicle."
: "Spawn lane is clear. Stored vehicles can be retrieved immediately.",
),
),
h(
"span",
{ className: "garage-footer-copy" },
session.spawnBlocked
? "Spawn lane is blocked. Clear the bay before retrieving another vehicle."
: "Spawn lane is clear. Stored vehicles can be retrieved immediately.",
),
),
h(
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Store Rules",
),
h(
"span",
{ className: "garage-footer-copy" },
"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.",
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Store Rules",
),
h(
"span",
{ className: "garage-footer-copy" },
"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.",
),
),
),
),

View File

@ -776,49 +776,53 @@
),
h(
"footer",
{ className: "garage-footer" },
{ className: "garage-footer-bar" },
h(
"div",
{ className: "garage-footer-block" },
{ className: "garage-footer" },
h(
"span",
{ className: "garage-footer-title" },
"Storage Capacity",
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Storage Capacity",
),
h(
"span",
{ className: "garage-footer-copy" },
`${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`,
),
),
h(
"span",
{ className: "garage-footer-copy" },
`${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`,
),
),
h(
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Retrieval Window",
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Retrieval Window",
),
h(
"span",
{ className: "garage-footer-copy" },
session.spawnBlocked
? "Spawn lane is blocked. Clear the bay before retrieving another vehicle."
: "Spawn lane is clear. Stored vehicles can be retrieved immediately.",
),
),
h(
"span",
{ className: "garage-footer-copy" },
session.spawnBlocked
? "Spawn lane is blocked. Clear the bay before retrieving another vehicle."
: "Spawn lane is clear. Stored vehicles can be retrieved immediately.",
),
),
h(
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Store Rules",
),
h(
"span",
{ className: "garage-footer-copy" },
"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.",
"div",
{ className: "garage-footer-block" },
h(
"span",
{ className: "garage-footer-title" },
"Store Rules",
),
h(
"span",
{ className: "garage-footer-copy" },
"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.",
),
),
),
),

View File

@ -217,10 +217,16 @@ button:disabled {
gap: 0.65rem;
}
.garage-footer-bar {
width: 100%;
border-top: 1px solid rgb(18 54 93 / 0.1);
}
.garage-footer {
width: min(100%, 1613px);
margin: 0 auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0.95rem 1.25rem 1.15rem;
border-top: 1px solid rgb(18 54 93 / 0.1);
}
.garage-meter-stack {

View File

@ -3908,7 +3908,7 @@ ${scopeSelector} .home-feedback {
"div",
{ className: "app-shell" },
WindowTitleBar({
kicker: "ORBIS Workspace",
kicker: "FORGE ORBIS",
title: "Global Organization Network",
onClose: closeRegistry,
closeLabel: "Close organization interface",
@ -3939,7 +3939,7 @@ ${scopeSelector} .home-feedback {
"div",
{ className: "app-shell" },
WindowTitleBar({
kicker: "ORBIS Workspace",
kicker: "FORGE ORBIS",
title: "Global Organization Network",
onClose: closeRegistry,
closeLabel: "Close organization interface",

View File

@ -75,7 +75,7 @@
"div",
{ className: "app-shell" },
WindowTitleBar({
kicker: "ORBIS Workspace",
kicker: "FORGE ORBIS",
title: "Global Organization Network",
onClose: closeRegistry,
closeLabel: "Close organization interface",
@ -106,7 +106,7 @@
"div",
{ className: "app-shell" },
WindowTitleBar({
kicker: "ORBIS Workspace",
kicker: "FORGE ORBIS",
title: "Global Organization Network",
onClose: closeRegistry,
closeLabel: "Close organization interface",

View File

@ -1623,13 +1623,19 @@ ${scopeSelector} .store-panel-intro {
border-bottom: 1px solid var(--store-accent-line);
}
${scopeSelector} .store-footer-bar {
width: 100%;
border-top: 1px solid rgb(18 54 93 / 0.1);
background: transparent;
}
${scopeSelector} .store-footer {
width: min(100%, 1613px);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
padding: 0.95rem 1.25rem 1.15rem;
border-top: 1px solid rgb(18 54 93 / 0.1);
background: transparent;
}
${scopeSelector} .footer-block {
@ -2039,39 +2045,51 @@ ${scopeSelector} .store-toast.is-error {
),
h(
"footer",
{ className: "store-footer" },
{ className: "store-footer-bar" },
h(
"div",
{ className: "footer-block" },
{ className: "store-footer" },
h(
"span",
{ className: "footer-title" },
"Procurement Desk",
"div",
{ className: "footer-block" },
h(
"span",
{ className: "footer-title" },
"Procurement Desk",
),
h(
"span",
{ className: "footer-copy" },
"Authorized supply browsing for personnel loadout preparation and mission staging.",
),
),
h(
"span",
{ className: "footer-copy" },
"Authorized supply browsing for personnel loadout preparation and mission staging.",
"div",
{ className: "footer-block" },
h(
"span",
{ className: "footer-title" },
"Catalog Scope",
),
h(
"span",
{ className: "footer-copy" },
"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
),
),
),
h(
"div",
{ className: "footer-block" },
h("span", { className: "footer-title" }, "Catalog Scope"),
h(
"span",
{ className: "footer-copy" },
"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
),
),
h(
"div",
{ className: "footer-block" },
h("span", { className: "footer-title" }, "Purchase Access"),
h(
"span",
{ className: "footer-copy" },
`${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`,
"div",
{ className: "footer-block" },
h(
"span",
{ className: "footer-title" },
"Purchase Access",
),
h(
"span",
{ className: "footer-copy" },
`${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`,
),
),
),
),

View File

@ -194,13 +194,19 @@ ${scopeSelector} .store-panel-intro {
border-bottom: 1px solid var(--store-accent-line);
}
${scopeSelector} .store-footer-bar {
width: 100%;
border-top: 1px solid rgb(18 54 93 / 0.1);
background: transparent;
}
${scopeSelector} .store-footer {
width: min(100%, 1613px);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
padding: 0.95rem 1.25rem 1.15rem;
border-top: 1px solid rgb(18 54 93 / 0.1);
background: transparent;
}
${scopeSelector} .footer-block {
@ -610,39 +616,51 @@ ${scopeSelector} .store-toast.is-error {
),
h(
"footer",
{ className: "store-footer" },
{ className: "store-footer-bar" },
h(
"div",
{ className: "footer-block" },
{ className: "store-footer" },
h(
"span",
{ className: "footer-title" },
"Procurement Desk",
"div",
{ className: "footer-block" },
h(
"span",
{ className: "footer-title" },
"Procurement Desk",
),
h(
"span",
{ className: "footer-copy" },
"Authorized supply browsing for personnel loadout preparation and mission staging.",
),
),
h(
"span",
{ className: "footer-copy" },
"Authorized supply browsing for personnel loadout preparation and mission staging.",
"div",
{ className: "footer-block" },
h(
"span",
{ className: "footer-title" },
"Catalog Scope",
),
h(
"span",
{ className: "footer-copy" },
"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
),
),
),
h(
"div",
{ className: "footer-block" },
h("span", { className: "footer-title" }, "Catalog Scope"),
h(
"span",
{ className: "footer-copy" },
"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
),
),
h(
"div",
{ className: "footer-block" },
h("span", { className: "footer-title" }, "Purchase Access"),
h(
"span",
{ className: "footer-copy" },
`${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`,
"div",
{ className: "footer-block" },
h(
"span",
{ className: "footer-title" },
"Purchase Access",
),
h(
"span",
{ className: "footer-copy" },
`${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`,
),
),
),
),