Refactor client UI stores and normalize docs formatting
- Rework org and store UI state modules (rename/move store/getter files, add runtime and bridge wiring) - Update store UI components and page structure (navbar/cart split, new StoreView flow) - Apply broad markdown/YAML/HTML/CSS/JS formatting cleanup across docs, templates, and workflows
This commit is contained in:
parent
06f1bad878
commit
d178e39164
@ -1,12 +1,17 @@
|
||||
# Contributing Setup & Guidelines
|
||||
|
||||
## Setting up the Development Environment
|
||||
|
||||
### 1. Clone the repository from GitHub
|
||||
|
||||
### 2. Install HEMTT
|
||||
|
||||
The latest version of HEMTT can be installed by running:
|
||||
|
||||
```cmd
|
||||
winget install hemtt
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
|
||||
|
||||
@ -1,25 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us improve
|
||||
title: ''
|
||||
title: ""
|
||||
labels: kind/bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Attachments
|
||||
|
||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a feature to be added
|
||||
title: ''
|
||||
title: ""
|
||||
labels: kind/feature-request
|
||||
---
|
||||
|
||||
## Describe the feature that you would like
|
||||
|
||||
A clear and concise description of the feature you'd want.
|
||||
|
||||
## Possible alternatives
|
||||
|
||||
Possible alternatives to your suggestion.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the feature here.
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
**When merged this pull request will:**
|
||||
|
||||
- Describe what this pull request will do
|
||||
- Each change in a separate line
|
||||
|
||||
### Important
|
||||
|
||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
||||
- [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied.
|
||||
- [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`.
|
||||
|
||||
<!-- Known issues that need to be addressed -->
|
||||
|
||||
### Known Issues
|
||||
|
||||
- [ ] Issue
|
||||
|
||||
@ -105,15 +105,18 @@ sequenceDiagram
|
||||
## 🚀 **Performance Characteristics**
|
||||
|
||||
### **Access Times**
|
||||
|
||||
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
|
||||
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
|
||||
- **Client Cache**: `< 0.1ms` (Local object access)
|
||||
|
||||
### **Cache Hit Ratios**
|
||||
|
||||
- **Hot Cache**: `~95%` (Active players)
|
||||
- **Cold Storage**: `~5%` (New connections, cache misses)
|
||||
|
||||
### **Memory Usage**
|
||||
|
||||
- **Server Registry**: `~1KB per active player`
|
||||
- **Client Cache**: `~500B per player object`
|
||||
- **Redis**: `~2KB per player (persistent)`
|
||||
|
||||
12
LICENSE.md
12
LICENSE.md
@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY
|
||||
|
||||
With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions:
|
||||
|
||||
* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
|
||||
* **Noncommercial** - You may not use this material for any commercial purposes.
|
||||
* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
|
||||
* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
|
||||
- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
|
||||
- **Noncommercial** - You may not use this material for any commercial purposes.
|
||||
- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
|
||||
- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
|
||||
|
||||
---
|
||||
|
||||
@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
||||
2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
2. upon express reinstatement by the Licensor.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
||||
### Bohemia Interactive Notices
|
||||
|
||||
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||
|
||||
53
README.md
53
README.md
@ -33,6 +33,7 @@ graph TD
|
||||
```
|
||||
|
||||
**Communication Flow**:
|
||||
|
||||
- **Clients** → Use events (`CBA_Events`) to communicate with server
|
||||
- **Server** → Calls Rust extension via `callExtension`
|
||||
- **Extension** → Manages Redis connection pool and data operations
|
||||
@ -87,12 +88,14 @@ forge/
|
||||
|
||||
1. Clone the repository from Gitea
|
||||
2. Install HEMTT
|
||||
The latest version of HEMTT can be installed by running:
|
||||
The latest version of HEMTT can be installed by running:
|
||||
|
||||
```cmd
|
||||
winget install hemtt
|
||||
```
|
||||
|
||||
### Coding Guidelines
|
||||
|
||||
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
|
||||
|
||||
### Building the Extension
|
||||
@ -143,14 +146,18 @@ private _update = createHashMapFromArray [["bank", 1500]];
|
||||
## Core Modules
|
||||
|
||||
### Models
|
||||
|
||||
Defines strict data structures with built-in validation:
|
||||
|
||||
- `Actor`: Player data (stats, inventory, position)
|
||||
- `Org`: Organization/clan data (members, roles, metadata)
|
||||
|
||||
[Documentation](lib/models/README.md)
|
||||
|
||||
### Repositories
|
||||
|
||||
Manages data persistence with Redis:
|
||||
|
||||
- Hash-based storage for structured data
|
||||
- Set-based storage for collections
|
||||
- Generic over Redis client implementations
|
||||
@ -158,7 +165,9 @@ Manages data persistence with Redis:
|
||||
[Documentation](lib/repositories/README.md)
|
||||
|
||||
### Services
|
||||
|
||||
Implements business logic and orchestration:
|
||||
|
||||
- Get-or-create patterns
|
||||
- Data validation and transformation
|
||||
- Complex workflows
|
||||
@ -166,7 +175,9 @@ Implements business logic and orchestration:
|
||||
[Documentation](lib/services/README.md)
|
||||
|
||||
### Extension
|
||||
|
||||
Arma 3 interface layer:
|
||||
|
||||
- Command routing and parsing
|
||||
- Session management
|
||||
- Error handling and logging
|
||||
@ -174,7 +185,9 @@ Arma 3 interface layer:
|
||||
[Documentation](arma/server/extension/README.md)
|
||||
|
||||
### Client Mod
|
||||
|
||||
Client-side SQF addon that provides:
|
||||
|
||||
- **UI Components**: Player interfaces for inventory, organizations, banking
|
||||
- **Event Handlers**: CBA event listeners for server communication
|
||||
- **Optimistic Caching**: Local data caching for instant UI updates
|
||||
@ -182,6 +195,7 @@ Client-side SQF addon that provides:
|
||||
- **Input Validation**: Client-side validation before server requests
|
||||
|
||||
The client mod communicates with the server using **CBA Events**, ensuring:
|
||||
|
||||
- No direct extension calls from clients (security)
|
||||
- Event-driven architecture for scalability
|
||||
- Automatic state synchronization across all clients
|
||||
@ -190,28 +204,32 @@ The client mod communicates with the server using **CBA Events**, ensuring:
|
||||
## Available Commands
|
||||
|
||||
### Actor Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `actor:get` | Retrieve actor data by UID |
|
||||
| `actor:create` | Create a new actor |
|
||||
| `actor:update` | Update actor fields |
|
||||
| `actor:exists` | Check if actor exists |
|
||||
| `actor:delete` | Delete actor data |
|
||||
|
||||
| Command | Description |
|
||||
| -------------- | -------------------------- |
|
||||
| `actor:get` | Retrieve actor data by UID |
|
||||
| `actor:create` | Create a new actor |
|
||||
| `actor:update` | Update actor fields |
|
||||
| `actor:exists` | Check if actor exists |
|
||||
| `actor:delete` | Delete actor data |
|
||||
|
||||
### Organization Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `org:get` | Retrieve organization data |
|
||||
| `org:create` | Create a new organization |
|
||||
| `org:update` | Update organization fields |
|
||||
| `org:exists` | Check if organization exists |
|
||||
| `org:delete` | Delete organization |
|
||||
| `org:add_member` | Add member to organization |
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ------------------------------- |
|
||||
| `org:get` | Retrieve organization data |
|
||||
| `org:create` | Create a new organization |
|
||||
| `org:update` | Update organization fields |
|
||||
| `org:exists` | Check if organization exists |
|
||||
| `org:delete` | Delete organization |
|
||||
| `org:add_member` | Add member to organization |
|
||||
| `org:remove_member` | Remove member from organization |
|
||||
| `org:get_members` | Get all organization members |
|
||||
| `org:get_members` | Get all organization members |
|
||||
|
||||
### Redis Operations
|
||||
|
||||
Direct Redis operations for advanced use cases:
|
||||
|
||||
- **Common**: Key-value operations (set, get, incr, decr, del)
|
||||
- **Hash**: Structured data (hset, hget, hgetall, hdel)
|
||||
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
|
||||
@ -264,6 +282,7 @@ if (_response find "Error:" == 0) then {
|
||||
## Logging
|
||||
|
||||
Logs are automatically created in `@forge_server/logs/`:
|
||||
|
||||
- `actor.log` - Actor operations
|
||||
- `org.log` - Organization operations
|
||||
- `redis.log` - Redis connection and operations
|
||||
|
||||
5
arma/client/.github/CONTRIBUTING.md
vendored
5
arma/client/.github/CONTRIBUTING.md
vendored
@ -1,12 +1,17 @@
|
||||
# Contributing Setup & Guidelines
|
||||
|
||||
## Setting up the Development Environment
|
||||
|
||||
### 1. Clone the repository from GitHub
|
||||
|
||||
### 2. Install HEMTT
|
||||
|
||||
The latest version of HEMTT can be installed by running:
|
||||
|
||||
```cmd
|
||||
winget install hemtt
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
|
||||
|
||||
@ -1,25 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us improve
|
||||
title: ''
|
||||
title: ""
|
||||
labels: kind/bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Attachments
|
||||
|
||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a feature to be added
|
||||
title: ''
|
||||
title: ""
|
||||
labels: kind/feature-request
|
||||
---
|
||||
|
||||
## Describe the feature that you would like
|
||||
|
||||
A clear and concise description of the feature you'd want.
|
||||
|
||||
## Possible alternatives
|
||||
|
||||
Possible alternatives to your suggestion.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the feature here.
|
||||
|
||||
4
arma/client/.github/PULL_REQUEST_TEMPLATE.md
vendored
4
arma/client/.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,12 +1,16 @@
|
||||
**When merged this pull request will:**
|
||||
|
||||
- Describe what this pull request will do
|
||||
- Each change in a separate line
|
||||
|
||||
### Important
|
||||
|
||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
||||
- [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied.
|
||||
- [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`.
|
||||
|
||||
<!-- Known issues that need to be addressed -->
|
||||
|
||||
### Known Issues
|
||||
|
||||
- [ ] Issue
|
||||
|
||||
24
arma/client/.github/workflows/check.yml
vendored
24
arma/client/.github/workflows/check.yml
vendored
@ -12,17 +12,17 @@ jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout the source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Config
|
||||
run: python tools/config_style_checker.py
|
||||
- name: Check for BOM
|
||||
uses: arma-actions/bom-check@master
|
||||
with:
|
||||
path: "addons"
|
||||
- name: Validate Config
|
||||
run: python tools/config_style_checker.py
|
||||
- name: Check for BOM
|
||||
uses: arma-actions/bom-check@master
|
||||
with:
|
||||
path: "addons"
|
||||
|
||||
- name: Setup HEMTT
|
||||
uses: arma-actions/hemtt@v1
|
||||
- name: Run HEMTT check
|
||||
run: hemtt check --pedantic
|
||||
- name: Setup HEMTT
|
||||
uses: arma-actions/hemtt@v1
|
||||
- name: Run HEMTT check
|
||||
run: hemtt check --pedantic
|
||||
|
||||
@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY
|
||||
|
||||
With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions:
|
||||
|
||||
* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
|
||||
* **Noncommercial** - You may not use this material for any commercial purposes.
|
||||
* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
|
||||
* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
|
||||
- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
|
||||
- **Noncommercial** - You may not use this material for any commercial purposes.
|
||||
- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
|
||||
- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
|
||||
|
||||
---
|
||||
|
||||
@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
||||
2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
2. upon express reinstatement by the Licensor.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
||||
### Bohemia Interactive Notices
|
||||
|
||||
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||
|
||||
@ -18,10 +18,13 @@
|
||||
The project is entirely **open-source** and any contributions are welcome.
|
||||
|
||||
## Core Features
|
||||
|
||||
- Feature
|
||||
|
||||
## Contributing
|
||||
|
||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Forge Client is licensed under [APL-SA](./LICENSE.md).
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_actor
|
||||
===================
|
||||
# forge_client_actor
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,39 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Interaction Menu</title>
|
||||
<!-- <link rel="stylesheet" href="style.css"> -->
|
||||
<!--
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Interaction Menu</title>
|
||||
<!-- <link rel="stylesheet" href="style.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\\actor\\ui\\_site\\style.css",
|
||||
),
|
||||
A3API.RequestFile(
|
||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
|
||||
),
|
||||
]).then(([css, js]) => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
<script>
|
||||
Promise.all([
|
||||
A3API.RequestFile(
|
||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
|
||||
),
|
||||
A3API.RequestFile(
|
||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
|
||||
),
|
||||
]).then(([css, js]) => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- <script src="script.js"></script> -->
|
||||
</body>
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- <script src="script.js"></script> -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -13,11 +13,11 @@ function h(tag, props = {}, ...children) {
|
||||
|
||||
if (props) {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (key.startsWith('on') && typeof value === 'function') {
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||
} else if (key === 'className') {
|
||||
} else if (key === "className") {
|
||||
el.className = value;
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
} else if (key === "style" && typeof value === "object") {
|
||||
Object.assign(el.style, value);
|
||||
} else {
|
||||
el.setAttribute(key, value);
|
||||
@ -25,13 +25,13 @@ function h(tag, props = {}, ...children) {
|
||||
});
|
||||
}
|
||||
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string' || typeof child === 'number') {
|
||||
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 => {
|
||||
child.forEach((c) => {
|
||||
if (c instanceof Node) el.appendChild(c);
|
||||
});
|
||||
}
|
||||
@ -52,7 +52,7 @@ function render(component, container) {
|
||||
|
||||
function _render() {
|
||||
if (_rootContainer && _rootComponent) {
|
||||
_rootContainer.innerHTML = '';
|
||||
_rootContainer.innerHTML = "";
|
||||
_rootContainer.appendChild(_rootComponent());
|
||||
}
|
||||
}
|
||||
@ -211,7 +211,9 @@ function actorReducer(state = initialState, action) {
|
||||
if (definition) {
|
||||
newMenuItems.push(definition);
|
||||
} else {
|
||||
console.warn(`No definition found for: ${type} - ${value}`);
|
||||
console.warn(
|
||||
`No definition found for: ${type} - ${value}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid action format:", actionItem);
|
||||
@ -232,7 +234,10 @@ function actorReducer(state = initialState, action) {
|
||||
|
||||
case ActionTypes.ADD_ACTION:
|
||||
const definition = state.actionDefinitions[action.payload];
|
||||
if (definition && !state.menuItems.find((item) => item.id === definition.id)) {
|
||||
if (
|
||||
definition &&
|
||||
!state.menuItems.find((item) => item.id === definition.id)
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
menuItems: [...state.menuItems, definition],
|
||||
@ -243,7 +248,9 @@ function actorReducer(state = initialState, action) {
|
||||
case ActionTypes.REMOVE_ACTION:
|
||||
return {
|
||||
...state,
|
||||
menuItems: state.menuItems.filter((item) => item.id !== action.payload),
|
||||
menuItems: state.menuItems.filter(
|
||||
(item) => item.id !== action.payload,
|
||||
),
|
||||
};
|
||||
|
||||
case ActionTypes.CLEAR_ACTIONS:
|
||||
@ -299,7 +306,8 @@ const selectors = {
|
||||
getAvailableActions: (state) => state.availableActions,
|
||||
getBaseMenuItems: (state) => state.baseMenuItems,
|
||||
getActionDefinitions: (state) => state.actionDefinitions,
|
||||
getMenuItemById: (state, id) => state.menuItems.find((item) => item.id === id),
|
||||
getMenuItemById: (state, id) =>
|
||||
state.menuItems.find((item) => item.id === id),
|
||||
getMenuItemsCount: (state) => state.menuItems.length,
|
||||
};
|
||||
|
||||
@ -312,9 +320,11 @@ let tooltipEl = null;
|
||||
|
||||
function createTooltip() {
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = h('div', { className: 'radial-tooltip' },
|
||||
h('div', { className: 'tooltip-title' }),
|
||||
h('div', { className: 'tooltip-description' })
|
||||
tooltipEl = h(
|
||||
"div",
|
||||
{ className: "radial-tooltip" },
|
||||
h("div", { className: "tooltip-title" }),
|
||||
h("div", { className: "tooltip-description" }),
|
||||
);
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
@ -323,16 +333,17 @@ function createTooltip() {
|
||||
|
||||
function showTooltip(item, x, y) {
|
||||
const tooltip = createTooltip();
|
||||
tooltip.querySelector('.tooltip-title').textContent = item.title;
|
||||
tooltip.querySelector('.tooltip-description').textContent = item.description;
|
||||
tooltip.querySelector(".tooltip-title").textContent = item.title;
|
||||
tooltip.querySelector(".tooltip-description").textContent =
|
||||
item.description;
|
||||
tooltip.style.left = `${x + 15}px`;
|
||||
tooltip.style.top = `${y + 10}px`;
|
||||
tooltip.classList.add('visible');
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (tooltipEl) {
|
||||
tooltipEl.classList.remove('visible');
|
||||
tooltipEl.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,36 +361,42 @@ function RadialItem({ item, index, total, onClick }) {
|
||||
const x = centerX + menuRadius * Math.cos(angle) - itemSize / 2;
|
||||
const y = centerY + menuRadius * Math.sin(angle) - itemSize / 2;
|
||||
|
||||
const el = h('div', {
|
||||
className: 'radial-item',
|
||||
style: {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
const el = h(
|
||||
"div",
|
||||
{
|
||||
className: "radial-item",
|
||||
style: {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
},
|
||||
onClick: () => onClick(item),
|
||||
},
|
||||
onClick: () => onClick(item)
|
||||
},
|
||||
h('div', { className: 'radial-item-title' }, item.title)
|
||||
h("div", { className: "radial-item-title" }, item.title),
|
||||
);
|
||||
|
||||
// Add tooltip events
|
||||
el.addEventListener('mouseenter', (e) => showTooltip(item, e.clientX, e.clientY));
|
||||
el.addEventListener('mousemove', (e) => {
|
||||
if (tooltipEl && tooltipEl.classList.contains('visible')) {
|
||||
el.addEventListener("mouseenter", (e) =>
|
||||
showTooltip(item, e.clientX, e.clientY),
|
||||
);
|
||||
el.addEventListener("mousemove", (e) => {
|
||||
if (tooltipEl && tooltipEl.classList.contains("visible")) {
|
||||
tooltipEl.style.left = `${e.clientX + 15}px`;
|
||||
tooltipEl.style.top = `${e.clientY + 10}px`;
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseleave', hideTooltip);
|
||||
el.addEventListener("mouseleave", hideTooltip);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function RadialCenter({ onClose }) {
|
||||
return h('div', {
|
||||
className: 'radial-center',
|
||||
onClick: onClose
|
||||
},
|
||||
h('div', { className: 'center-label' }, 'Close')
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
className: "radial-center",
|
||||
onClick: onClose,
|
||||
},
|
||||
h("div", { className: "center-label" }, "Close"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -393,7 +410,7 @@ function RadialMenu() {
|
||||
event: item.action,
|
||||
data: {},
|
||||
};
|
||||
if (typeof A3API !== 'undefined') {
|
||||
if (typeof A3API !== "undefined") {
|
||||
A3API.SendAlert(JSON.stringify(alert));
|
||||
}
|
||||
};
|
||||
@ -404,27 +421,31 @@ function RadialMenu() {
|
||||
event: "actor::close::menu",
|
||||
data: {},
|
||||
};
|
||||
if (typeof A3API !== 'undefined') {
|
||||
if (typeof A3API !== "undefined") {
|
||||
A3API.SendAlert(JSON.stringify(alert));
|
||||
}
|
||||
};
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
return h('div', { className: 'empty-state' },
|
||||
h('p', null, 'No actions available')
|
||||
return h(
|
||||
"div",
|
||||
{ className: "empty-state" },
|
||||
h("p", null, "No actions available"),
|
||||
);
|
||||
}
|
||||
|
||||
return h('div', { className: 'radial-menu' },
|
||||
return h(
|
||||
"div",
|
||||
{ className: "radial-menu" },
|
||||
RadialCenter({ onClose: handleClose }),
|
||||
menuItems.map((item, index) =>
|
||||
RadialItem({
|
||||
item,
|
||||
index,
|
||||
total: menuItems.length,
|
||||
onClick: handleItemClick
|
||||
})
|
||||
)
|
||||
onClick: handleItemClick,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -460,14 +481,14 @@ function initializeMenu() {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.getElementById('app');
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
render(App, root);
|
||||
initialized = true;
|
||||
console.log("Interaction menu initialized successfully");
|
||||
|
||||
// Request initial data from A3API
|
||||
if (typeof A3API !== 'undefined') {
|
||||
if (typeof A3API !== "undefined") {
|
||||
const alert = {
|
||||
event: "actor::get::actions",
|
||||
data: {},
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
--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);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--menu-radius: 160px;
|
||||
--item-size: 80px;
|
||||
}
|
||||
@ -22,7 +23,11 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--bg-app);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_bank
|
||||
===================
|
||||
# forge_client_bank
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -14,7 +14,11 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@ -182,7 +186,7 @@ button {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
&+& {
|
||||
& + & {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,41 +1,45 @@
|
||||
<!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"> -->
|
||||
<!--
|
||||
<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);
|
||||
<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 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>
|
||||
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>
|
||||
|
||||
@ -10,24 +10,24 @@ 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') {
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||
} else if (key === 'className') {
|
||||
} else if (key === "className") {
|
||||
el.className = value;
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
} 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') {
|
||||
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 => {
|
||||
child.forEach((c) => {
|
||||
if (c instanceof Node) el.appendChild(c);
|
||||
});
|
||||
}
|
||||
@ -46,7 +46,7 @@ function render(component, container) {
|
||||
|
||||
function _render() {
|
||||
if (_rootContainer && _rootComponent) {
|
||||
_rootContainer.innerHTML = '';
|
||||
_rootContainer.innerHTML = "";
|
||||
_rootContainer.appendChild(_rootComponent());
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@ const createSignal = (initialValue) => {
|
||||
let _val = initialValue;
|
||||
const getValue = () => _val;
|
||||
const setValue = (newValue) => {
|
||||
_val = typeof newValue === 'function' ? newValue(_val) : newValue;
|
||||
_val = typeof newValue === "function" ? newValue(_val) : newValue;
|
||||
_render();
|
||||
};
|
||||
return [getValue, setValue];
|
||||
@ -65,19 +65,21 @@ const createSignal = (initialValue) => {
|
||||
// #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('');
|
||||
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')
|
||||
return h(
|
||||
"div",
|
||||
{ className: "header", style: { marginBottom: "2rem" } },
|
||||
h("h1", null, "ATM TERMINAL"),
|
||||
h("p", null, "Global Financial Network"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -86,185 +88,342 @@ function PinView() {
|
||||
|
||||
const handleNumClick = (num) => {
|
||||
if (currentPin.length < 4) {
|
||||
setPin(prev => prev + num);
|
||||
setPin((prev) => prev + num);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => setPin('');
|
||||
const handleClear = () => setPin("");
|
||||
|
||||
const handleEnter = () => {
|
||||
if (currentPin.length === 4) {
|
||||
const state = typeof store !== 'undefined' ? store.getState() : { pin: '1234' };
|
||||
const state =
|
||||
typeof store !== "undefined"
|
||||
? store.getState()
|
||||
: { pin: "1234" };
|
||||
if (currentPin === state.pin) {
|
||||
setView('menu');
|
||||
setView("menu");
|
||||
} else {
|
||||
setMessage('Incorrect PIN');
|
||||
setPin('');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
setMessage("Incorrect PIN");
|
||||
setPin("");
|
||||
setTimeout(() => setMessage(""), 2000);
|
||||
}
|
||||
} else {
|
||||
setMessage('Invalid PIN Length');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
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)) || '----'
|
||||
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(
|
||||
"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))
|
||||
)
|
||||
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'
|
||||
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", 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')
|
||||
)
|
||||
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 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') {
|
||||
if (typeof store !== "undefined") {
|
||||
store.dispatch(withdraw(amount));
|
||||
}
|
||||
sendEvent('atm::withdraw', { amount });
|
||||
sendEvent("atm::withdraw", { amount });
|
||||
setMessage(`Please take your cash: $${amount.toLocaleString()}`);
|
||||
setTimeout(() => {
|
||||
setMessage('');
|
||||
setView('menu');
|
||||
setMessage("");
|
||||
setView("menu");
|
||||
}, 3000);
|
||||
} else {
|
||||
setMessage('Insufficient Funds');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
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: "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')
|
||||
)
|
||||
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 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);
|
||||
setCustomAmount((prev) => prev + num);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => setCustomAmount('');
|
||||
const handleClear = () => setCustomAmount("");
|
||||
|
||||
const handleEnter = () => {
|
||||
const amount = parseInt(currentAmount, 10);
|
||||
if (amount > 0) {
|
||||
if (bankBalance >= amount) {
|
||||
if (typeof store !== 'undefined') {
|
||||
if (typeof store !== "undefined") {
|
||||
store.dispatch(withdraw(amount));
|
||||
}
|
||||
sendEvent('atm::withdraw', { amount });
|
||||
setMessage(`Please take your cash: $${amount.toLocaleString()}`);
|
||||
sendEvent("atm::withdraw", { amount });
|
||||
setMessage(
|
||||
`Please take your cash: $${amount.toLocaleString()}`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
setMessage('');
|
||||
setView('menu');
|
||||
setMessage("");
|
||||
setView("menu");
|
||||
}, 3000);
|
||||
} else {
|
||||
setMessage('Insufficient Funds');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
setMessage("Insufficient Funds");
|
||||
setTimeout(() => setMessage(""), 2000);
|
||||
}
|
||||
} else {
|
||||
setMessage('Invalid Amount');
|
||||
setTimeout(() => setMessage(''), 2000);
|
||||
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: "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'
|
||||
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(
|
||||
"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: { 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')
|
||||
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 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()
|
||||
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",
|
||||
),
|
||||
h('button', { className: 'kiosk-btn', style: { width: '100%', maxWidth: '300px', margin: '0 auto' }, onClick: () => setView('menu') }, 'Return to Menu')
|
||||
);
|
||||
}
|
||||
|
||||
@ -272,23 +431,22 @@ function App() {
|
||||
const view = getView();
|
||||
|
||||
let mainContent;
|
||||
if (view === 'pin') {
|
||||
if (view === "pin") {
|
||||
mainContent = PinView();
|
||||
} else if (view === 'menu') {
|
||||
} else if (view === "menu") {
|
||||
mainContent = MenuView();
|
||||
} else if (view === 'withdraw') {
|
||||
} else if (view === "withdraw") {
|
||||
mainContent = WithdrawView();
|
||||
} else if (view === 'custom_withdraw') {
|
||||
} else if (view === "custom_withdraw") {
|
||||
mainContent = CustomWithdrawView();
|
||||
} else if (view === 'balance') {
|
||||
} else if (view === "balance") {
|
||||
mainContent = BalanceView();
|
||||
}
|
||||
|
||||
return h('main', null,
|
||||
h('div', { className: 'container' },
|
||||
Header(),
|
||||
mainContent
|
||||
)
|
||||
return h(
|
||||
"main",
|
||||
null,
|
||||
h("div", { className: "container" }, Header(), mainContent),
|
||||
);
|
||||
}
|
||||
|
||||
@ -297,10 +455,10 @@ function App() {
|
||||
//=============================================================================
|
||||
|
||||
function sendEvent(event, data) {
|
||||
if (typeof A3API !== 'undefined') {
|
||||
if (typeof A3API !== "undefined") {
|
||||
A3API.SendAlert(JSON.stringify({ event, data }));
|
||||
} else {
|
||||
console.log('Event:', event, 'Data:', data);
|
||||
console.log("Event:", event, "Data:", data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,20 +471,20 @@ let initialized = false;
|
||||
function initATM() {
|
||||
if (initialized) return;
|
||||
|
||||
const root = document.getElementById('app');
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
if (typeof store !== 'undefined') {
|
||||
if (typeof store !== "undefined") {
|
||||
store.subscribe(() => _render());
|
||||
}
|
||||
|
||||
render(App, root);
|
||||
initialized = true;
|
||||
console.log('[ATM] Interface initialized');
|
||||
console.log("[ATM] Interface initialized");
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initATM);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initATM);
|
||||
} else {
|
||||
initATM();
|
||||
}
|
||||
|
||||
@ -22,7 +22,11 @@ body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-app);
|
||||
@ -198,7 +202,7 @@ main {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
}
|
||||
|
||||
.content {
|
||||
@ -244,7 +248,7 @@ button {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
&+& {
|
||||
& + & {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,47 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<!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"> -->
|
||||
<!--
|
||||
<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);
|
||||
<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 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>
|
||||
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>
|
||||
|
||||
@ -10,26 +10,31 @@ 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') {
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||
} else if (key === 'className') {
|
||||
} else if (key === "className") {
|
||||
el.className = value;
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
} else if (key === "style" && typeof value === "object") {
|
||||
Object.assign(el.style, value);
|
||||
} else if (key === 'disabled' || key === 'checked' || key === 'selected' || key === 'readonly') {
|
||||
} 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') {
|
||||
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 => {
|
||||
child.forEach((c) => {
|
||||
if (c instanceof Node) el.appendChild(c);
|
||||
});
|
||||
}
|
||||
@ -48,7 +53,7 @@ function render(component, container) {
|
||||
|
||||
function _render() {
|
||||
if (_rootContainer && _rootComponent) {
|
||||
_rootContainer.innerHTML = '';
|
||||
_rootContainer.innerHTML = "";
|
||||
_rootContainer.appendChild(_rootComponent());
|
||||
}
|
||||
}
|
||||
@ -59,52 +64,92 @@ function _render() {
|
||||
|
||||
function Navbar() {
|
||||
const state = store.getState();
|
||||
const uid = state.uid || 'Unknown';
|
||||
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')
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
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')
|
||||
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",
|
||||
),
|
||||
),
|
||||
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')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -112,34 +157,77 @@ 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'),
|
||||
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())
|
||||
);
|
||||
})
|
||||
)
|
||||
? 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(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,26 +237,26 @@ function DepositWithdrawForm() {
|
||||
const cashBalance = state.accounts.cash;
|
||||
|
||||
const getAmount = () => {
|
||||
const input = document.getElementById('deposit-withdraw-amount');
|
||||
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 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');
|
||||
console.log("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
if (amount > cashBalance) {
|
||||
console.log('Insufficient cash');
|
||||
console.log("Insufficient cash");
|
||||
return;
|
||||
}
|
||||
sendEvent('bank::deposit', { amount });
|
||||
sendEvent("bank::deposit", { amount });
|
||||
store.dispatch(deposit(amount));
|
||||
clearInput();
|
||||
};
|
||||
@ -176,37 +264,70 @@ function DepositWithdrawForm() {
|
||||
const handleWithdraw = () => {
|
||||
const amount = getAmount();
|
||||
if (!amount || amount <= 0) {
|
||||
console.log('Please enter a valid amount');
|
||||
console.log("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
if (amount > bankBalance) {
|
||||
console.log('Insufficient funds');
|
||||
console.log("Insufficient funds");
|
||||
return;
|
||||
}
|
||||
sendEvent('bank::withdraw', { amount });
|
||||
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())
|
||||
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",
|
||||
),
|
||||
),
|
||||
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')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -218,52 +339,70 @@ function TransferForm() {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const amount = parseFloat(formData.get('amount'));
|
||||
const playerId = formData.get('playerId');
|
||||
const amount = parseFloat(formData.get("amount"));
|
||||
const playerId = formData.get("playerId");
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
console.log('Please enter a valid amount');
|
||||
console.log("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = store.getState();
|
||||
|
||||
if (!playerId) {
|
||||
console.log('Please select a recipient');
|
||||
console.log("Please select a recipient");
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount > currentState.accounts.bank) {
|
||||
console.log('Insufficient funds');
|
||||
console.log("Insufficient funds");
|
||||
return;
|
||||
}
|
||||
|
||||
sendEvent('bank::transfer', { from: 'bank', amount, target: playerId });
|
||||
store.dispatch(transfer('bank', amount, 'player'));
|
||||
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 => {
|
||||
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));
|
||||
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)
|
||||
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(
|
||||
"div",
|
||||
null,
|
||||
h("label", null, "Amount"),
|
||||
h("input", {
|
||||
name: "amount",
|
||||
type: "number",
|
||||
placeholder: "0.00",
|
||||
}),
|
||||
),
|
||||
h('button', { type: 'submit' }, 'Send Funds')
|
||||
)
|
||||
h("button", { type: "submit" }, "Send Funds"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -272,66 +411,127 @@ function BankDashboard() {
|
||||
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()
|
||||
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: { textAlign: 'center', color: 'var(--text-muted)', fontSize: '1.1rem', marginBottom: '1rem' } },
|
||||
'Pending: ',
|
||||
h('span', { style: { color: '#fbbf24', fontWeight: 'bold' } }, '$' + earnings.toLocaleString())
|
||||
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",
|
||||
),
|
||||
),
|
||||
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())
|
||||
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')
|
||||
)
|
||||
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')
|
||||
)
|
||||
)
|
||||
)
|
||||
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' },
|
||||
return h(
|
||||
"div",
|
||||
{ className: "app-shell" },
|
||||
WindowTitleBar(),
|
||||
h('main', null,
|
||||
h(
|
||||
"main",
|
||||
null,
|
||||
Navbar(),
|
||||
h('div', { className: 'container' },
|
||||
BankDashboard()
|
||||
),
|
||||
Footer()
|
||||
)
|
||||
h("div", { className: "container" }, BankDashboard()),
|
||||
Footer(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -340,10 +540,10 @@ function App() {
|
||||
//=============================================================================
|
||||
|
||||
function sendEvent(event, data) {
|
||||
if (typeof A3API !== 'undefined') {
|
||||
if (typeof A3API !== "undefined") {
|
||||
A3API.SendAlert(JSON.stringify({ event, data }));
|
||||
} else {
|
||||
console.log('Event:', event, 'Data:', data);
|
||||
console.log("Event:", event, "Data:", data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -356,20 +556,20 @@ let initialized = false;
|
||||
function initBank() {
|
||||
if (initialized) return;
|
||||
|
||||
const root = document.getElementById('app');
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
if (typeof store !== 'undefined') {
|
||||
if (typeof store !== "undefined") {
|
||||
store.subscribe(() => _render());
|
||||
}
|
||||
|
||||
render(App, root);
|
||||
initialized = true;
|
||||
console.log('[Bank] Interface initialized');
|
||||
console.log("[Bank] Interface initialized");
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBank);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initBank);
|
||||
} else {
|
||||
initBank();
|
||||
}
|
||||
|
||||
@ -20,13 +20,13 @@ function createStore(reducer) {
|
||||
|
||||
const dispatch = (action) => {
|
||||
state = reducer(state, action);
|
||||
listeners.forEach(listener => listener());
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const subscribe = (listener) => {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter(l => l !== listener);
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
};
|
||||
|
||||
@ -41,27 +41,27 @@ function createStore(reducer) {
|
||||
// ============================================================================
|
||||
|
||||
const initialState = {
|
||||
uid: '',
|
||||
uid: "",
|
||||
accounts: {
|
||||
bank: 0,
|
||||
cash: 0,
|
||||
earnings: 0,
|
||||
org: 0
|
||||
org: 0,
|
||||
},
|
||||
pin: '1234',
|
||||
transactions: []
|
||||
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';
|
||||
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
|
||||
@ -69,34 +69,34 @@ const UPDATE_PIN = 'UPDATE_PIN';
|
||||
|
||||
const deposit = (amount) => ({
|
||||
type: DEPOSIT,
|
||||
payload: amount
|
||||
payload: amount,
|
||||
});
|
||||
|
||||
const depositEarnings = (amount) => ({
|
||||
type: DEPOSIT_EARNINGS,
|
||||
payload: amount
|
||||
payload: amount,
|
||||
});
|
||||
|
||||
const withdraw = (amount) => ({
|
||||
type: WITHDRAW,
|
||||
payload: amount
|
||||
payload: amount,
|
||||
});
|
||||
|
||||
const transfer = (from, amount, target) => ({
|
||||
type: TRANSFER,
|
||||
from: from,
|
||||
payload: amount,
|
||||
target: target
|
||||
target: target,
|
||||
});
|
||||
|
||||
const updateAccounts = (accounts) => ({
|
||||
type: UPDATE_ACCOUNTS,
|
||||
payload: accounts
|
||||
payload: accounts,
|
||||
});
|
||||
|
||||
const updatePin = (pin) => ({
|
||||
type: UPDATE_PIN,
|
||||
payload: pin
|
||||
payload: pin,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@ -107,7 +107,7 @@ function appReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case DEPOSIT:
|
||||
if (state.accounts.cash < action.payload) {
|
||||
console.warn('Insufficient cash!');
|
||||
console.warn("Insufficient cash!");
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
@ -115,21 +115,21 @@ function appReducer(state = initialState, action) {
|
||||
accounts: {
|
||||
...state.accounts,
|
||||
bank: state.accounts.bank + action.payload,
|
||||
cash: state.accounts.cash - action.payload
|
||||
cash: state.accounts.cash - action.payload,
|
||||
},
|
||||
transactions: [
|
||||
...state.transactions,
|
||||
{
|
||||
type: 'Deposit',
|
||||
type: "Deposit",
|
||||
amount: action.payload,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
]
|
||||
date: new Date().toLocaleString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case DEPOSIT_EARNINGS:
|
||||
if (state.accounts.earnings < action.payload) {
|
||||
console.warn('Insufficient earnings!');
|
||||
console.warn("Insufficient earnings!");
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
@ -137,21 +137,21 @@ function appReducer(state = initialState, action) {
|
||||
accounts: {
|
||||
...state.accounts,
|
||||
bank: state.accounts.bank + action.payload,
|
||||
earnings: state.accounts.earnings - action.payload
|
||||
earnings: state.accounts.earnings - action.payload,
|
||||
},
|
||||
transactions: [
|
||||
...state.transactions,
|
||||
{
|
||||
type: 'Deposit Earnings',
|
||||
type: "Deposit Earnings",
|
||||
amount: action.payload,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
]
|
||||
date: new Date().toLocaleString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case WITHDRAW:
|
||||
if (state.accounts.bank < action.payload) {
|
||||
console.warn('Insufficient funds!');
|
||||
console.warn("Insufficient funds!");
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
@ -159,22 +159,22 @@ function appReducer(state = initialState, action) {
|
||||
accounts: {
|
||||
...state.accounts,
|
||||
bank: state.accounts.bank - action.payload,
|
||||
cash: state.accounts.cash + action.payload
|
||||
cash: state.accounts.cash + action.payload,
|
||||
},
|
||||
transactions: [
|
||||
...state.transactions,
|
||||
{
|
||||
type: 'Withdraw',
|
||||
type: "Withdraw",
|
||||
amount: action.payload,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
]
|
||||
date: new Date().toLocaleString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case TRANSFER:
|
||||
const fromAccount = action.from;
|
||||
if (state.accounts[fromAccount] < action.payload) {
|
||||
console.warn('Insufficient funds!');
|
||||
console.warn("Insufficient funds!");
|
||||
return state;
|
||||
}
|
||||
|
||||
@ -187,13 +187,13 @@ function appReducer(state = initialState, action) {
|
||||
transactions: [
|
||||
...state.transactions,
|
||||
{
|
||||
type: 'Transfer',
|
||||
type: "Transfer",
|
||||
amount: action.payload,
|
||||
from: fromAccount,
|
||||
target: action.target,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
]
|
||||
date: new Date().toLocaleString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case UPDATE_ACCOUNTS:
|
||||
@ -201,20 +201,20 @@ function appReducer(state = initialState, action) {
|
||||
...state,
|
||||
accounts: {
|
||||
...state.accounts,
|
||||
...action.payload
|
||||
}
|
||||
...action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
case UPDATE_PIN:
|
||||
return {
|
||||
...state,
|
||||
pin: String(action.payload)
|
||||
pin: String(action.payload),
|
||||
};
|
||||
|
||||
case 'SET_UID':
|
||||
case "SET_UID":
|
||||
return {
|
||||
...state,
|
||||
uid: action.payload
|
||||
uid: action.payload,
|
||||
};
|
||||
|
||||
default:
|
||||
@ -238,13 +238,15 @@ const store = createStore(appReducer);
|
||||
* @param {Object} data - Event data
|
||||
*/
|
||||
function sendEvent(event, data) {
|
||||
if (typeof A3API !== 'undefined') {
|
||||
A3API.SendAlert(JSON.stringify({
|
||||
event: event,
|
||||
data: data
|
||||
}));
|
||||
if (typeof A3API !== "undefined") {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event: event,
|
||||
data: data,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.log('Event:', event, 'Data:', data);
|
||||
console.log("Event:", event, "Data:", data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -253,7 +255,7 @@ function sendEvent(event, data) {
|
||||
* @param {Object} data - Account data from Arma 3
|
||||
*/
|
||||
function syncDataFromArma(data) {
|
||||
if (data && typeof data === 'object') {
|
||||
if (data && typeof data === "object") {
|
||||
const accounts = {};
|
||||
|
||||
if (data.bank !== undefined) accounts.bank = data.bank;
|
||||
@ -268,7 +270,7 @@ function syncDataFromArma(data) {
|
||||
|
||||
// Update UID if provided
|
||||
if (data.uid !== undefined && data.uid !== store.getState().uid) {
|
||||
store.dispatch({ type: 'SET_UID', payload: data.uid });
|
||||
store.dispatch({ type: "SET_UID", payload: data.uid });
|
||||
}
|
||||
|
||||
// Update pin if provided
|
||||
@ -276,9 +278,12 @@ function syncDataFromArma(data) {
|
||||
store.dispatch(updatePin(data.pin));
|
||||
}
|
||||
|
||||
console.log('[Store] Synced data from Arma:', store.getState().accounts);
|
||||
console.log(
|
||||
"[Store] Synced data from Arma:",
|
||||
store.getState().accounts,
|
||||
);
|
||||
} else {
|
||||
console.warn('[Store] Invalid data received:', data);
|
||||
console.warn("[Store] Invalid data received:", data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,14 +292,14 @@ function syncDataFromArma(data) {
|
||||
// ============================================================================
|
||||
|
||||
// Request initial data from Arma on load
|
||||
if (typeof A3API !== 'undefined') {
|
||||
if (typeof A3API !== "undefined") {
|
||||
// Delay request slightly to ensure everything is loaded
|
||||
setTimeout(() => {
|
||||
sendEvent('bank::sync', {});
|
||||
sendEvent("bank::sync", {});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Expose sync function globally for Arma to call
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
window.syncDataFromArma = syncDataFromArma;
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_common
|
||||
===================
|
||||
# forge_client_common
|
||||
|
||||
Common functionality shared between addons.
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_garage
|
||||
===================
|
||||
# forge_client_garage
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,195 +1,253 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vehicle Garage</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<!--
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vehicle Garage</title>
|
||||
<link rel="stylesheet" href="style.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\\garage\\ui\\_site\\style.css",
|
||||
),
|
||||
A3API.RequestFile(
|
||||
"forge\\forge_client\\addons\\garage\\ui\\_site\\script.js",
|
||||
),
|
||||
]).then(([css, js]) => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
<script>
|
||||
Promise.all([
|
||||
A3API.RequestFile(
|
||||
"forge\\forge_client\\addons\\garage\\ui\\_site\\style.css",
|
||||
),
|
||||
A3API.RequestFile(
|
||||
"forge\\forge_client\\addons\\garage\\ui\\_site\\script.js",
|
||||
),
|
||||
]).then(([css, js]) => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
const script = document.createElement("script");
|
||||
script.text = js;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="garage-container">
|
||||
<!-- Header Section -->
|
||||
<div class="garage-header">
|
||||
<div class="garage-logo">
|
||||
<div class="logo-icon">🚗</div>
|
||||
</div>
|
||||
<div class="garage-info">
|
||||
<h1 class="garage-title">Vehicle Garage</h1>
|
||||
<p class="garage-subtitle">Vehicle Management System</p>
|
||||
</div>
|
||||
<div class="garage-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Stored</span>
|
||||
<span class="stat-value" id="storedCount">12</span>
|
||||
<body>
|
||||
<div class="garage-container">
|
||||
<!-- Header Section -->
|
||||
<div class="garage-header">
|
||||
<div class="garage-logo">
|
||||
<div class="logo-icon">🚗</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Active</span>
|
||||
<span class="stat-value" id="activeCount">2</span>
|
||||
<div class="garage-info">
|
||||
<h1 class="garage-title">Vehicle Garage</h1>
|
||||
<p class="garage-subtitle">Vehicle Management System</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Capacity</span>
|
||||
<span class="stat-value" id="capacityCount">20</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="action-btn close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="garage-content">
|
||||
<!-- Left Panel - Filters -->
|
||||
<div class="garage-panel filters-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Filters</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<!-- Status Filter -->
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Status</h3>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="stored">Stored</button>
|
||||
<button class="filter-btn" data-filter="active">Active</button>
|
||||
</div>
|
||||
<div class="garage-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Stored</span>
|
||||
<span class="stat-value" id="storedCount">12</span>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Vehicle Type</h3>
|
||||
<div class="type-list">
|
||||
<button class="type-item active" data-type="all">
|
||||
<span class="type-icon">📦</span>
|
||||
<span class="type-name">All Types</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="car">
|
||||
<span class="type-icon">🚗</span>
|
||||
<span class="type-name">Cars</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="truck">
|
||||
<span class="type-icon">🚛</span>
|
||||
<span class="type-name">Trucks</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="air">
|
||||
<span class="type-icon">🚁</span>
|
||||
<span class="type-name">Aircraft</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="sea">
|
||||
<span class="type-icon">🚤</span>
|
||||
<span class="type-name">Boats</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Active</span>
|
||||
<span class="stat-value" id="activeCount">2</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Search</h3>
|
||||
<input type="text" class="search-input" id="searchInput" placeholder="Search vehicles...">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Capacity</span>
|
||||
<span class="stat-value" id="capacityCount">20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Panel - Vehicle Grid -->
|
||||
<div class="garage-panel vehicles-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Your Vehicles</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="vehicles-grid" id="vehiclesGrid">
|
||||
<!-- Vehicles will be dynamically generated -->
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="action-btn close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Vehicle Details -->
|
||||
<div class="garage-panel details-panel" id="detailsPanel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Vehicle Details</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="no-selection" id="noSelection">
|
||||
<div class="no-selection-icon">🚗</div>
|
||||
<p>Select a vehicle to view details</p>
|
||||
<!-- Main Content -->
|
||||
<div class="garage-content">
|
||||
<!-- Left Panel - Filters -->
|
||||
<div class="garage-panel filters-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Filters</h2>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-details" id="vehicleDetails" style="display: none;">
|
||||
<div class="detail-header">
|
||||
<div class="detail-icon" id="detailIcon">🚗</div>
|
||||
<div class="detail-info">
|
||||
<h3 class="detail-name" id="detailName">Vehicle Name</h3>
|
||||
<p class="detail-type" id="detailType">Type</p>
|
||||
<div class="panel-content">
|
||||
<!-- Status Filter -->
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Status</h3>
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
class="filter-btn active"
|
||||
data-filter="all"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button class="filter-btn" data-filter="stored">
|
||||
Stored
|
||||
</button>
|
||||
<button class="filter-btn" data-filter="active">
|
||||
Active
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Status</span>
|
||||
<span class="detail-value" id="detailStatus">Stored</span>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Condition</span>
|
||||
<span class="detail-value" id="detailCondition">100%</span>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Fuel</span>
|
||||
<span class="detail-value" id="detailFuel">100%</span>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Location</span>
|
||||
<span class="detail-value" id="detailLocation">Garage A</span>
|
||||
<!-- Type Filter -->
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Vehicle Type</h3>
|
||||
<div class="type-list">
|
||||
<button
|
||||
class="type-item active"
|
||||
data-type="all"
|
||||
>
|
||||
<span class="type-icon">📦</span>
|
||||
<span class="type-name">All Types</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="car">
|
||||
<span class="type-icon">🚗</span>
|
||||
<span class="type-name">Cars</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="truck">
|
||||
<span class="type-icon">🚛</span>
|
||||
<span class="type-name">Trucks</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="air">
|
||||
<span class="type-icon">🚁</span>
|
||||
<span class="type-name">Aircraft</span>
|
||||
</button>
|
||||
<button class="type-item" data-type="sea">
|
||||
<span class="type-icon">🚤</span>
|
||||
<span class="type-name">Boats</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="detail-btn spawn-btn" id="spawnBtn">
|
||||
<span class="btn-icon">🚀</span>
|
||||
<span class="btn-text">Spawn Vehicle</span>
|
||||
</button>
|
||||
<button class="detail-btn store-btn" id="storeBtn" style="display: none;">
|
||||
<span class="btn-icon">📦</span>
|
||||
<span class="btn-text">Store Vehicle</span>
|
||||
</button>
|
||||
<!-- Search -->
|
||||
<div class="filter-section">
|
||||
<h3 class="filter-title">Search</h3>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="searchInput"
|
||||
placeholder="Search vehicles..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Panel - Vehicle Grid -->
|
||||
<div class="garage-panel vehicles-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Your Vehicles</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="vehicles-grid" id="vehiclesGrid">
|
||||
<!-- Vehicles will be dynamically generated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Vehicle Details -->
|
||||
<div class="garage-panel details-panel" id="detailsPanel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Vehicle Details</h2>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="no-selection" id="noSelection">
|
||||
<div class="no-selection-icon">🚗</div>
|
||||
<p>Select a vehicle to view details</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-specs">
|
||||
<h4 class="specs-title">Specifications</h4>
|
||||
<div class="specs-list">
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">Seats</span>
|
||||
<span class="spec-value" id="detailSeats">4</span>
|
||||
<div
|
||||
class="vehicle-details"
|
||||
id="vehicleDetails"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="detail-header">
|
||||
<div class="detail-icon" id="detailIcon">
|
||||
🚗
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">Speed</span>
|
||||
<span class="spec-value" id="detailSpeed">180 km/h</span>
|
||||
<div class="detail-info">
|
||||
<h3 class="detail-name" id="detailName">
|
||||
Vehicle Name
|
||||
</h3>
|
||||
<p class="detail-type" id="detailType">
|
||||
Type
|
||||
</p>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">Cargo</span>
|
||||
<span class="spec-value" id="detailCargo">200 kg</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Status</span>
|
||||
<span class="detail-value" id="detailStatus"
|
||||
>Stored</span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Condition</span>
|
||||
<span
|
||||
class="detail-value"
|
||||
id="detailCondition"
|
||||
>100%</span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Fuel</span>
|
||||
<span class="detail-value" id="detailFuel"
|
||||
>100%</span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-stat">
|
||||
<span class="detail-label">Location</span>
|
||||
<span
|
||||
class="detail-value"
|
||||
id="detailLocation"
|
||||
>Garage A</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<button
|
||||
class="detail-btn spawn-btn"
|
||||
id="spawnBtn"
|
||||
>
|
||||
<span class="btn-icon">🚀</span>
|
||||
<span class="btn-text">Spawn Vehicle</span>
|
||||
</button>
|
||||
<button
|
||||
class="detail-btn store-btn"
|
||||
id="storeBtn"
|
||||
style="display: none"
|
||||
>
|
||||
<span class="btn-icon">📦</span>
|
||||
<span class="btn-text">Store Vehicle</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-specs">
|
||||
<h4 class="specs-title">Specifications</h4>
|
||||
<div class="specs-list">
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">Seats</span>
|
||||
<span
|
||||
class="spec-value"
|
||||
id="detailSeats"
|
||||
>4</span
|
||||
>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">Speed</span>
|
||||
<span
|
||||
class="spec-value"
|
||||
id="detailSpeed"
|
||||
>180 km/h</span
|
||||
>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="spec-label">Cargo</span>
|
||||
<span
|
||||
class="spec-value"
|
||||
id="detailCargo"
|
||||
>200 kg</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -197,9 +255,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -7,98 +7,242 @@
|
||||
const mockData = {
|
||||
vehicles: [
|
||||
// Cars
|
||||
{ id: 1, name: "Sedan", type: "car", icon: "🚗", status: "stored", condition: 95, fuel: 80, location: "Garage A", seats: 4, speed: "180 km/h", cargo: "200 kg" },
|
||||
{ id: 2, name: "Sports Car", type: "car", icon: "🏎️", status: "stored", condition: 100, fuel: 100, location: "Garage A", seats: 2, speed: "250 km/h", cargo: "50 kg" },
|
||||
{ id: 3, name: "SUV", type: "car", icon: "🚙", status: "active", condition: 85, fuel: 60, location: "In Use", seats: 6, speed: "160 km/h", cargo: "400 kg" },
|
||||
{ id: 4, name: "Hatchback", type: "car", icon: "🚗", status: "stored", condition: 90, fuel: 75, location: "Garage B", seats: 4, speed: "170 km/h", cargo: "250 kg" },
|
||||
{
|
||||
id: 1,
|
||||
name: "Sedan",
|
||||
type: "car",
|
||||
icon: "🚗",
|
||||
status: "stored",
|
||||
condition: 95,
|
||||
fuel: 80,
|
||||
location: "Garage A",
|
||||
seats: 4,
|
||||
speed: "180 km/h",
|
||||
cargo: "200 kg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Sports Car",
|
||||
type: "car",
|
||||
icon: "🏎️",
|
||||
status: "stored",
|
||||
condition: 100,
|
||||
fuel: 100,
|
||||
location: "Garage A",
|
||||
seats: 2,
|
||||
speed: "250 km/h",
|
||||
cargo: "50 kg",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "SUV",
|
||||
type: "car",
|
||||
icon: "🚙",
|
||||
status: "active",
|
||||
condition: 85,
|
||||
fuel: 60,
|
||||
location: "In Use",
|
||||
seats: 6,
|
||||
speed: "160 km/h",
|
||||
cargo: "400 kg",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Hatchback",
|
||||
type: "car",
|
||||
icon: "🚗",
|
||||
status: "stored",
|
||||
condition: 90,
|
||||
fuel: 75,
|
||||
location: "Garage B",
|
||||
seats: 4,
|
||||
speed: "170 km/h",
|
||||
cargo: "250 kg",
|
||||
},
|
||||
|
||||
// Trucks
|
||||
{ id: 5, name: "Pickup Truck", type: "truck", icon: "🚛", status: "stored", condition: 88, fuel: 70, location: "Garage A", seats: 2, speed: "140 km/h", cargo: "800 kg" },
|
||||
{ id: 6, name: "Delivery Van", type: "truck", icon: "🚚", status: "stored", condition: 92, fuel: 85, location: "Garage B", seats: 3, speed: "130 km/h", cargo: "1200 kg" },
|
||||
{ id: 7, name: "Heavy Truck", type: "truck", icon: "🚛", status: "active", condition: 75, fuel: 50, location: "In Use", seats: 2, speed: "120 km/h", cargo: "2000 kg" },
|
||||
{ id: 8, name: "Box Truck", type: "truck", icon: "📦", status: "stored", condition: 80, fuel: 65, location: "Garage A", seats: 3, speed: "110 km/h", cargo: "1500 kg" },
|
||||
{
|
||||
id: 5,
|
||||
name: "Pickup Truck",
|
||||
type: "truck",
|
||||
icon: "🚛",
|
||||
status: "stored",
|
||||
condition: 88,
|
||||
fuel: 70,
|
||||
location: "Garage A",
|
||||
seats: 2,
|
||||
speed: "140 km/h",
|
||||
cargo: "800 kg",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Delivery Van",
|
||||
type: "truck",
|
||||
icon: "🚚",
|
||||
status: "stored",
|
||||
condition: 92,
|
||||
fuel: 85,
|
||||
location: "Garage B",
|
||||
seats: 3,
|
||||
speed: "130 km/h",
|
||||
cargo: "1200 kg",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Heavy Truck",
|
||||
type: "truck",
|
||||
icon: "🚛",
|
||||
status: "active",
|
||||
condition: 75,
|
||||
fuel: 50,
|
||||
location: "In Use",
|
||||
seats: 2,
|
||||
speed: "120 km/h",
|
||||
cargo: "2000 kg",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Box Truck",
|
||||
type: "truck",
|
||||
icon: "📦",
|
||||
status: "stored",
|
||||
condition: 80,
|
||||
fuel: 65,
|
||||
location: "Garage A",
|
||||
seats: 3,
|
||||
speed: "110 km/h",
|
||||
cargo: "1500 kg",
|
||||
},
|
||||
|
||||
// Aircraft
|
||||
{ id: 9, name: "Helicopter", type: "air", icon: "🚁", status: "stored", condition: 95, fuel: 90, location: "Helipad", seats: 6, speed: "280 km/h", cargo: "500 kg" },
|
||||
{ id: 10, name: "Light Plane", type: "air", icon: "✈️", status: "stored", condition: 100, fuel: 100, location: "Hangar", seats: 4, speed: "320 km/h", cargo: "300 kg" },
|
||||
{
|
||||
id: 9,
|
||||
name: "Helicopter",
|
||||
type: "air",
|
||||
icon: "🚁",
|
||||
status: "stored",
|
||||
condition: 95,
|
||||
fuel: 90,
|
||||
location: "Helipad",
|
||||
seats: 6,
|
||||
speed: "280 km/h",
|
||||
cargo: "500 kg",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Light Plane",
|
||||
type: "air",
|
||||
icon: "✈️",
|
||||
status: "stored",
|
||||
condition: 100,
|
||||
fuel: 100,
|
||||
location: "Hangar",
|
||||
seats: 4,
|
||||
speed: "320 km/h",
|
||||
cargo: "300 kg",
|
||||
},
|
||||
|
||||
// Boats
|
||||
{ id: 11, name: "Speedboat", type: "sea", icon: "🚤", status: "stored", condition: 93, fuel: 80, location: "Marina", seats: 4, speed: "100 km/h", cargo: "150 kg" },
|
||||
{ id: 12, name: "Yacht", type: "sea", icon: "🛥️", status: "stored", condition: 98, fuel: 95, location: "Marina", seats: 12, speed: "60 km/h", cargo: "800 kg" }
|
||||
]
|
||||
{
|
||||
id: 11,
|
||||
name: "Speedboat",
|
||||
type: "sea",
|
||||
icon: "🚤",
|
||||
status: "stored",
|
||||
condition: 93,
|
||||
fuel: 80,
|
||||
location: "Marina",
|
||||
seats: 4,
|
||||
speed: "100 km/h",
|
||||
cargo: "150 kg",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "Yacht",
|
||||
type: "sea",
|
||||
icon: "🛥️",
|
||||
status: "stored",
|
||||
condition: 98,
|
||||
fuel: 95,
|
||||
location: "Marina",
|
||||
seats: 12,
|
||||
speed: "60 km/h",
|
||||
cargo: "800 kg",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// State
|
||||
let selectedVehicle = null;
|
||||
let statusFilter = 'all';
|
||||
let typeFilter = 'all';
|
||||
let searchQuery = '';
|
||||
let statusFilter = "all";
|
||||
let typeFilter = "all";
|
||||
let searchQuery = "";
|
||||
|
||||
// Icons by type
|
||||
const typeIcons = {
|
||||
car: '🚗',
|
||||
truck: '🚛',
|
||||
air: '🚁',
|
||||
sea: '🚤'
|
||||
car: "🚗",
|
||||
truck: "🚛",
|
||||
air: "🚁",
|
||||
sea: "🚤",
|
||||
};
|
||||
|
||||
// Initialize
|
||||
function initGarage() {
|
||||
console.log('Garage interface initializing...');
|
||||
console.log("Garage interface initializing...");
|
||||
|
||||
setupEventHandlers();
|
||||
renderVehicles();
|
||||
updateStats();
|
||||
|
||||
console.log('Garage interface initialized');
|
||||
console.log("Garage interface initialized");
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
function setupEventHandlers() {
|
||||
// Close button
|
||||
const closeBtn = document.querySelector('.close-btn');
|
||||
const closeBtn = document.querySelector(".close-btn");
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
console.log('Closing garage...');
|
||||
sendEvent('garage::close', {});
|
||||
closeBtn.addEventListener("click", () => {
|
||||
console.log("Closing garage...");
|
||||
sendEvent("garage::close", {});
|
||||
});
|
||||
}
|
||||
|
||||
// Status filters
|
||||
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||
filterBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const filterBtns = document.querySelectorAll(".filter-btn");
|
||||
filterBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
filterBtns.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
statusFilter = btn.dataset.filter;
|
||||
renderVehicles();
|
||||
});
|
||||
});
|
||||
|
||||
// Type filters
|
||||
const typeItems = document.querySelectorAll('.type-item');
|
||||
typeItems.forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
typeItems.forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
const typeItems = document.querySelectorAll(".type-item");
|
||||
typeItems.forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
typeItems.forEach((i) => i.classList.remove("active"));
|
||||
item.classList.add("active");
|
||||
typeFilter = item.dataset.type;
|
||||
renderVehicles();
|
||||
});
|
||||
});
|
||||
|
||||
// Search
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
searchQuery = e.target.value.toLowerCase();
|
||||
renderVehicles();
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn button
|
||||
const spawnBtn = document.getElementById('spawnBtn');
|
||||
const spawnBtn = document.getElementById("spawnBtn");
|
||||
if (spawnBtn) {
|
||||
spawnBtn.addEventListener('click', () => {
|
||||
spawnBtn.addEventListener("click", () => {
|
||||
if (selectedVehicle) {
|
||||
spawnVehicle(selectedVehicle);
|
||||
}
|
||||
@ -106,9 +250,9 @@ function setupEventHandlers() {
|
||||
}
|
||||
|
||||
// Store button
|
||||
const storeBtn = document.getElementById('storeBtn');
|
||||
const storeBtn = document.getElementById("storeBtn");
|
||||
if (storeBtn) {
|
||||
storeBtn.addEventListener('click', () => {
|
||||
storeBtn.addEventListener("click", () => {
|
||||
if (selectedVehicle) {
|
||||
storeVehicle(selectedVehicle);
|
||||
}
|
||||
@ -118,38 +262,39 @@ function setupEventHandlers() {
|
||||
|
||||
// Render vehicles
|
||||
function renderVehicles() {
|
||||
const vehiclesGrid = document.getElementById('vehiclesGrid');
|
||||
const vehiclesGrid = document.getElementById("vehiclesGrid");
|
||||
if (!vehiclesGrid) return;
|
||||
|
||||
vehiclesGrid.innerHTML = '';
|
||||
vehiclesGrid.innerHTML = "";
|
||||
|
||||
// Filter vehicles
|
||||
let filtered = mockData.vehicles;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(v => v.status === statusFilter);
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter((v) => v.status === statusFilter);
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(v => v.type === typeFilter);
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter((v) => v.type === typeFilter);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(v =>
|
||||
v.name.toLowerCase().includes(searchQuery) ||
|
||||
v.type.toLowerCase().includes(searchQuery)
|
||||
filtered = filtered.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(searchQuery) ||
|
||||
v.type.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
}
|
||||
|
||||
// Render vehicles
|
||||
filtered.forEach(vehicle => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'vehicle-card';
|
||||
filtered.forEach((vehicle) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "vehicle-card";
|
||||
if (selectedVehicle && selectedVehicle.id === vehicle.id) {
|
||||
card.classList.add('selected');
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
@ -159,7 +304,7 @@ function renderVehicles() {
|
||||
<div class="vehicle-status ${vehicle.status}">${vehicle.status}</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('click', () => selectVehicle(vehicle));
|
||||
card.addEventListener("click", () => selectVehicle(vehicle));
|
||||
vehiclesGrid.appendChild(card);
|
||||
});
|
||||
|
||||
@ -171,10 +316,10 @@ function selectVehicle(vehicle) {
|
||||
selectedVehicle = vehicle;
|
||||
|
||||
// Update selected state in grid
|
||||
document.querySelectorAll('.vehicle-card').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
document.querySelectorAll(".vehicle-card").forEach((card) => {
|
||||
card.classList.remove("selected");
|
||||
});
|
||||
event.currentTarget.classList.add('selected');
|
||||
event.currentTarget.classList.add("selected");
|
||||
|
||||
// Show details
|
||||
showVehicleDetails(vehicle);
|
||||
@ -182,48 +327,49 @@ function selectVehicle(vehicle) {
|
||||
|
||||
// Show vehicle details
|
||||
function showVehicleDetails(vehicle) {
|
||||
const noSelection = document.getElementById('noSelection');
|
||||
const vehicleDetails = document.getElementById('vehicleDetails');
|
||||
const spawnBtn = document.getElementById('spawnBtn');
|
||||
const storeBtn = document.getElementById('storeBtn');
|
||||
const noSelection = document.getElementById("noSelection");
|
||||
const vehicleDetails = document.getElementById("vehicleDetails");
|
||||
const spawnBtn = document.getElementById("spawnBtn");
|
||||
const storeBtn = document.getElementById("storeBtn");
|
||||
|
||||
if (noSelection) noSelection.style.display = 'none';
|
||||
if (vehicleDetails) vehicleDetails.style.display = 'flex';
|
||||
if (noSelection) noSelection.style.display = "none";
|
||||
if (vehicleDetails) vehicleDetails.style.display = "flex";
|
||||
|
||||
// Update details
|
||||
document.getElementById('detailIcon').textContent = vehicle.icon;
|
||||
document.getElementById('detailName').textContent = vehicle.name;
|
||||
document.getElementById('detailType').textContent = vehicle.type;
|
||||
document.getElementById('detailStatus').textContent = vehicle.status;
|
||||
document.getElementById('detailCondition').textContent = `${vehicle.condition}%`;
|
||||
document.getElementById('detailFuel').textContent = `${vehicle.fuel}%`;
|
||||
document.getElementById('detailLocation').textContent = vehicle.location;
|
||||
document.getElementById('detailSeats').textContent = vehicle.seats;
|
||||
document.getElementById('detailSpeed').textContent = vehicle.speed;
|
||||
document.getElementById('detailCargo').textContent = vehicle.cargo;
|
||||
document.getElementById("detailIcon").textContent = vehicle.icon;
|
||||
document.getElementById("detailName").textContent = vehicle.name;
|
||||
document.getElementById("detailType").textContent = vehicle.type;
|
||||
document.getElementById("detailStatus").textContent = vehicle.status;
|
||||
document.getElementById("detailCondition").textContent =
|
||||
`${vehicle.condition}%`;
|
||||
document.getElementById("detailFuel").textContent = `${vehicle.fuel}%`;
|
||||
document.getElementById("detailLocation").textContent = vehicle.location;
|
||||
document.getElementById("detailSeats").textContent = vehicle.seats;
|
||||
document.getElementById("detailSpeed").textContent = vehicle.speed;
|
||||
document.getElementById("detailCargo").textContent = vehicle.cargo;
|
||||
|
||||
// Show/hide action buttons based on status
|
||||
if (vehicle.status === 'stored') {
|
||||
spawnBtn.style.display = 'flex';
|
||||
storeBtn.style.display = 'none';
|
||||
if (vehicle.status === "stored") {
|
||||
spawnBtn.style.display = "flex";
|
||||
storeBtn.style.display = "none";
|
||||
} else {
|
||||
spawnBtn.style.display = 'none';
|
||||
storeBtn.style.display = 'flex';
|
||||
spawnBtn.style.display = "none";
|
||||
storeBtn.style.display = "flex";
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn vehicle
|
||||
function spawnVehicle(vehicle) {
|
||||
console.log('Spawning vehicle:', vehicle.name);
|
||||
console.log("Spawning vehicle:", vehicle.name);
|
||||
|
||||
// Update local state
|
||||
vehicle.status = 'active';
|
||||
vehicle.location = 'In Use';
|
||||
vehicle.status = "active";
|
||||
vehicle.location = "In Use";
|
||||
|
||||
sendEvent('garage::spawn', {
|
||||
sendEvent("garage::spawn", {
|
||||
vehicleId: vehicle.id,
|
||||
vehicleName: vehicle.name,
|
||||
vehicleType: vehicle.type
|
||||
vehicleType: vehicle.type,
|
||||
});
|
||||
|
||||
// Re-render
|
||||
@ -236,16 +382,16 @@ function spawnVehicle(vehicle) {
|
||||
|
||||
// Store vehicle
|
||||
function storeVehicle(vehicle) {
|
||||
console.log('Storing vehicle:', vehicle.name);
|
||||
console.log("Storing vehicle:", vehicle.name);
|
||||
|
||||
// Update local state
|
||||
vehicle.status = 'stored';
|
||||
vehicle.location = 'Garage A';
|
||||
vehicle.status = "stored";
|
||||
vehicle.location = "Garage A";
|
||||
|
||||
sendEvent('garage::store', {
|
||||
sendEvent("garage::store", {
|
||||
vehicleId: vehicle.id,
|
||||
vehicleName: vehicle.name,
|
||||
vehicleType: vehicle.type
|
||||
vehicleType: vehicle.type,
|
||||
});
|
||||
|
||||
// Re-render
|
||||
@ -258,13 +404,17 @@ function storeVehicle(vehicle) {
|
||||
|
||||
// Update stats
|
||||
function updateStats() {
|
||||
const stored = mockData.vehicles.filter(v => v.status === 'stored').length;
|
||||
const active = mockData.vehicles.filter(v => v.status === 'active').length;
|
||||
const stored = mockData.vehicles.filter(
|
||||
(v) => v.status === "stored",
|
||||
).length;
|
||||
const active = mockData.vehicles.filter(
|
||||
(v) => v.status === "active",
|
||||
).length;
|
||||
const capacity = mockData.vehicles.length + 6; // Mock capacity
|
||||
|
||||
document.getElementById('storedCount').textContent = stored;
|
||||
document.getElementById('activeCount').textContent = active;
|
||||
document.getElementById('capacityCount').textContent = capacity;
|
||||
document.getElementById("storedCount").textContent = stored;
|
||||
document.getElementById("activeCount").textContent = active;
|
||||
document.getElementById("capacityCount").textContent = capacity;
|
||||
}
|
||||
|
||||
// Update garage data from external source
|
||||
@ -276,16 +426,19 @@ function updateGarageData(data) {
|
||||
|
||||
// Update selected vehicle if it still exists
|
||||
if (selectedVehicle) {
|
||||
const updated = mockData.vehicles.find(v => v.id === selectedVehicle.id);
|
||||
const updated = mockData.vehicles.find(
|
||||
(v) => v.id === selectedVehicle.id,
|
||||
);
|
||||
if (updated) {
|
||||
selectedVehicle = updated;
|
||||
showVehicleDetails(updated);
|
||||
} else {
|
||||
selectedVehicle = null;
|
||||
const noSelection = document.getElementById('noSelection');
|
||||
const vehicleDetails = document.getElementById('vehicleDetails');
|
||||
if (noSelection) noSelection.style.display = 'flex';
|
||||
if (vehicleDetails) vehicleDetails.style.display = 'none';
|
||||
const noSelection = document.getElementById("noSelection");
|
||||
const vehicleDetails =
|
||||
document.getElementById("vehicleDetails");
|
||||
if (noSelection) noSelection.style.display = "flex";
|
||||
if (vehicleDetails) vehicleDetails.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -293,19 +446,21 @@ function updateGarageData(data) {
|
||||
|
||||
// Send event to Arma
|
||||
function sendEvent(event, data) {
|
||||
if (typeof A3API !== 'undefined') {
|
||||
A3API.SendAlert(JSON.stringify({
|
||||
event: event,
|
||||
data: data
|
||||
}));
|
||||
if (typeof A3API !== "undefined") {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event: event,
|
||||
data: data,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.log('Event:', event, 'Data:', data);
|
||||
console.log("Event:", event, "Data:", data);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initGarage);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initGarage);
|
||||
} else {
|
||||
initGarage();
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_locker
|
||||
===================
|
||||
# forge_client_locker
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_main
|
||||
===================
|
||||
# forge_client_main
|
||||
|
||||
Main Addon for forge-client
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_notifications
|
||||
===================
|
||||
# forge_client_notifications
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
forge_client_org
|
||||
===================
|
||||
# forge_client_org
|
||||
|
||||
Player organization UI and client integration.
|
||||
|
||||
UI Login Contract
|
||||
-----------------
|
||||
## UI Login Contract
|
||||
|
||||
The web UI sends the following request through `A3API.SendAlert`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "org::login::request",
|
||||
"data": {
|
||||
"email": "admin@spearnet.mil",
|
||||
"password": "secret"
|
||||
}
|
||||
"event": "org::login::request",
|
||||
"data": {
|
||||
"email": "admin@spearnet.mil",
|
||||
"password": "secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -81,6 +79,7 @@ _control ctrlWebBrowserAction [
|
||||
```
|
||||
|
||||
Current implementation:
|
||||
|
||||
- `fnc_handleUIEvents.sqf` now handles `org::login::request`
|
||||
- success hydrates the portal with `session` + `portalData`
|
||||
- failure returns a single `message` string for inline UI feedback
|
||||
|
||||
@ -71,9 +71,9 @@
|
||||
: null;
|
||||
|
||||
const view = store.getView();
|
||||
const portalPermissions =
|
||||
window.OrgPortal && window.OrgPortal.permissions
|
||||
? window.OrgPortal.permissions
|
||||
const portalGetters =
|
||||
window.OrgPortal && window.OrgPortal.getters
|
||||
? window.OrgPortal.getters
|
||||
: null;
|
||||
const portalActions =
|
||||
window.OrgPortal && window.OrgPortal.actions
|
||||
@ -125,9 +125,9 @@
|
||||
|
||||
if (view === "portal" && PortalApp) {
|
||||
const canLeaveOrg =
|
||||
portalPermissions &&
|
||||
typeof portalPermissions.canLeaveOrg === "function" &&
|
||||
portalPermissions.canLeaveOrg();
|
||||
portalGetters &&
|
||||
typeof portalGetters.canLeaveOrg === "function" &&
|
||||
portalGetters.canLeaveOrg();
|
||||
|
||||
return h(
|
||||
"div",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||
const { portalData } = OrgPortal.data;
|
||||
const actions = OrgPortal.actions;
|
||||
const getters = OrgPortal.getters;
|
||||
const scopeAttr = "data-ui-assets-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const assetsCardCss = `
|
||||
@ -82,7 +82,7 @@ ${scopeSelector} .org-simple-meta {
|
||||
{ className: "org-simple-meta" },
|
||||
SimpleStat(
|
||||
"Type",
|
||||
actions.formatAssetType(asset.type),
|
||||
getters.formatAssetType(asset.type),
|
||||
),
|
||||
SimpleStat("Quantity", asset.quantity),
|
||||
),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||
const permissions = OrgPortal.permissions;
|
||||
const getters = OrgPortal.getters;
|
||||
const actions = OrgPortal.actions;
|
||||
const scopeAttr = "data-ui-danger-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
@ -32,7 +32,7 @@ ${scopeSelector} .org-danger-copy p {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
ensureScopedStyle("portal-danger-card", dangerCardCss);
|
||||
|
||||
if (!permissions.canDisbandOrg()) {
|
||||
if (!getters.canDisbandOrg()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||
const { portalData } = OrgPortal.data;
|
||||
const actions = OrgPortal.actions;
|
||||
const getters = OrgPortal.getters;
|
||||
const scopeAttr = "data-ui-fleet-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const fleetCardCss = `
|
||||
@ -88,7 +88,7 @@ ${scopeSelector} .org-simple-meta {
|
||||
{ className: "org-simple-meta" },
|
||||
SimpleStat(
|
||||
"Type",
|
||||
actions.formatVehicleType(unit.type),
|
||||
getters.formatVehicleType(unit.type),
|
||||
),
|
||||
SimpleStat("Status", unit.status),
|
||||
SimpleStat("Damage", unit.damage),
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||
const store = OrgPortal.store;
|
||||
const permissions = OrgPortal.permissions;
|
||||
const getters = OrgPortal.getters;
|
||||
const actions = OrgPortal.actions;
|
||||
const scopeAttr = "data-ui-members-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
@ -56,7 +56,7 @@ ${scopeSelector} .org-name-row button {
|
||||
OrgPortal.componentFns.MembersCard = function MembersCard() {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const members = store.getMembers();
|
||||
const allowMemberManagement = permissions.canManageMembers();
|
||||
const allowMemberManagement = getters.canManageMembers();
|
||||
ensureScopedStyle("portal-members-card", membersCardCss);
|
||||
|
||||
return PanelCard({
|
||||
@ -71,7 +71,7 @@ ${scopeSelector} .org-name-row button {
|
||||
...members.map((member) => {
|
||||
const canRemoveMember =
|
||||
allowMemberManagement &&
|
||||
!actions.isProtectedMember(member);
|
||||
!getters.isProtectedMember(member);
|
||||
|
||||
return h(
|
||||
"article",
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||
const { portalData } = OrgPortal.data;
|
||||
const store = OrgPortal.store;
|
||||
const actions = OrgPortal.actions;
|
||||
const getters = OrgPortal.getters;
|
||||
const scopeAttr = "data-ui-overview-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const overviewCardCss = `
|
||||
@ -75,7 +75,7 @@ ${scopeSelector} .org-metric-grid {
|
||||
OrgPortal.componentFns.OverviewCard = function OverviewCard() {
|
||||
const MetricCard = OrgPortal.componentFns.MetricCard;
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const readiness = actions.getAssetReadiness();
|
||||
const readiness = getters.getAssetReadiness();
|
||||
const headquarters = portalData.org.headquarters || "ArmA Verse";
|
||||
ensureScopedStyle("portal-overview-card", overviewCardCss);
|
||||
|
||||
@ -112,7 +112,7 @@ ${scopeSelector} .org-metric-grid {
|
||||
h(
|
||||
"span",
|
||||
{ className: "org-meta-value" },
|
||||
actions.formatDisplayName(portalData.org.owner),
|
||||
getters.formatDisplayName(portalData.org.owner),
|
||||
),
|
||||
),
|
||||
h(
|
||||
@ -150,7 +150,7 @@ ${scopeSelector} .org-metric-grid {
|
||||
{ className: "org-metric-grid" },
|
||||
MetricCard(
|
||||
"Org Funds",
|
||||
actions.formatCurrency(store.getFunds()),
|
||||
getters.formatCurrency(store.getFunds()),
|
||||
"Organization treasury balance",
|
||||
),
|
||||
MetricCard(
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
const { h, ensureScopedStyle, createSignal } = OrgPortal.runtime;
|
||||
const { portalData } = OrgPortal.data;
|
||||
const store = OrgPortal.store;
|
||||
const permissions = OrgPortal.permissions;
|
||||
const getters = OrgPortal.getters;
|
||||
const actions = OrgPortal.actions;
|
||||
const scopeAttr = "data-ui-treasury-card";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
@ -199,7 +199,7 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
OrgPortal.componentFns.TreasuryCard = function TreasuryCard() {
|
||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||
const creditLines = store.getCreditLines();
|
||||
const allowTreasuryActions = permissions.canManageTreasury();
|
||||
const allowTreasuryActions = getters.canManageTreasury();
|
||||
const activeTab = getTreasuryTab();
|
||||
const isMenuOpen = getTreasuryMenuOpen();
|
||||
const activeCreditLabel =
|
||||
@ -323,7 +323,7 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
h(
|
||||
"strong",
|
||||
null,
|
||||
actions.formatCurrency(
|
||||
getters.formatCurrency(
|
||||
line.amount,
|
||||
),
|
||||
),
|
||||
@ -353,7 +353,7 @@ ${scopeSelector} .org-credit-line-empty {
|
||||
h(
|
||||
"strong",
|
||||
null,
|
||||
actions.formatCurrency(store.getFunds()),
|
||||
getters.formatCurrency(store.getFunds()),
|
||||
),
|
||||
),
|
||||
h(
|
||||
|
||||
@ -11,13 +11,13 @@
|
||||
"runtime.js",
|
||||
"logic\\registryStore.js",
|
||||
"logic\\portalStore.js",
|
||||
"logic\\portalPermissions.js",
|
||||
"logic\\portalGetters.js",
|
||||
"logic\\portalActions.js",
|
||||
"state.js",
|
||||
"useRegistryStore.js",
|
||||
"bridge.js",
|
||||
"portal\\data.js",
|
||||
"portal\\store.js",
|
||||
"portal\\permissions.js",
|
||||
"portal\\useStore.js",
|
||||
"portal\\getters.js",
|
||||
"portal\\actions.js",
|
||||
"components\\navbar.js",
|
||||
"components\\header.js",
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
SharedLogic.createPortalActions = function createPortalActions({
|
||||
portalData,
|
||||
store,
|
||||
permissions,
|
||||
getters,
|
||||
registryStore,
|
||||
}) {
|
||||
class OrgPortalActions {
|
||||
@ -12,59 +12,6 @@
|
||||
this.treasuryNoticeTimer = null;
|
||||
}
|
||||
|
||||
formatCurrency(value) {
|
||||
return "$" + value.toLocaleString();
|
||||
}
|
||||
|
||||
formatVehicleType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatAssetType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatDisplayName(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => {
|
||||
if (!part) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
part.charAt(0).toUpperCase() +
|
||||
part.slice(1).toLowerCase()
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
getAssetReadiness() {
|
||||
if (portalData.fleet.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = portalData.fleet.reduce(
|
||||
(sum, unit) => sum + (100 - parseInt(unit.damage, 10)),
|
||||
0,
|
||||
);
|
||||
return Math.round(total / portalData.fleet.length);
|
||||
}
|
||||
|
||||
showTreasuryNotice(type, text) {
|
||||
store.setTreasuryNotice({ type, text });
|
||||
|
||||
@ -88,58 +35,6 @@
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
getMemberName(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.name || "");
|
||||
}
|
||||
|
||||
return String(member || "");
|
||||
}
|
||||
|
||||
getMemberUid(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.uid || "");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
isOwnerMember(member) {
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isCurrentMember(member) {
|
||||
const session = window.OrgPortal?.data?.session || {};
|
||||
const memberUid = this.getMemberUid(member)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (memberUid && actorUid) {
|
||||
return memberUid === actorUid;
|
||||
}
|
||||
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isProtectedMember(member) {
|
||||
return (
|
||||
this.isOwnerMember(member) || this.isCurrentMember(member)
|
||||
);
|
||||
}
|
||||
|
||||
closePortal() {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
@ -164,7 +59,7 @@
|
||||
(type === "payroll" ||
|
||||
type === "transfer" ||
|
||||
type === "credit") &&
|
||||
!permissions.canManageTreasury()
|
||||
!getters.canManageTreasury()
|
||||
) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
@ -173,11 +68,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "disband" && !permissions.canDisbandOrg()) {
|
||||
if (type === "disband" && !getters.canDisbandOrg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "leave" && !permissions.canLeaveOrg()) {
|
||||
if (type === "leave" && !getters.canLeaveOrg()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -189,16 +84,16 @@
|
||||
}
|
||||
|
||||
removeMember(member) {
|
||||
if (!permissions.canManageMembers()) {
|
||||
if (!getters.canManageMembers()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isProtectedMember(member)) {
|
||||
if (getters.isProtectedMember(member)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const memberUid = this.getMemberUid(member);
|
||||
const memberName = this.getMemberName(member);
|
||||
const memberUid = getters.getMemberUid(member);
|
||||
const memberName = getters.getMemberName(member);
|
||||
|
||||
store.setMembers((currentMembers) =>
|
||||
currentMembers.filter((entry) =>
|
||||
@ -214,7 +109,7 @@
|
||||
}
|
||||
|
||||
disbandOrganization() {
|
||||
if (!permissions.canDisbandOrg()) {
|
||||
if (!getters.canDisbandOrg()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -236,7 +131,7 @@
|
||||
}
|
||||
|
||||
leaveOrganization() {
|
||||
if (!permissions.canLeaveOrg()) {
|
||||
if (!getters.canLeaveOrg()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -258,7 +153,7 @@
|
||||
}
|
||||
|
||||
runPayroll(amountPerMember) {
|
||||
if (!permissions.canManageTreasury()) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
@ -297,13 +192,13 @@
|
||||
store.setFunds(funds - total);
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`Payroll sent to ${members.length} members for ${this.formatCurrency(total)}.`,
|
||||
`Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
sendFundsToMember(memberName, amount) {
|
||||
if (!permissions.canManageTreasury()) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
@ -340,13 +235,13 @@
|
||||
store.setFunds(funds - amount);
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`${this.formatCurrency(amount)} sent to ${memberName}.`,
|
||||
`${getters.formatCurrency(amount)} sent to ${memberName}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
grantCreditLine(memberName, amount) {
|
||||
if (!permissions.canManageTreasury()) {
|
||||
if (!getters.canManageTreasury()) {
|
||||
this.showTreasuryNotice(
|
||||
"error",
|
||||
"Only the organization leader or CEO can manage treasury actions.",
|
||||
@ -391,7 +286,7 @@
|
||||
|
||||
this.showTreasuryNotice(
|
||||
"success",
|
||||
`Credit line of ${this.formatCurrency(amount)} assigned to ${memberName}.`,
|
||||
`Credit line of ${getters.formatCurrency(amount)} assigned to ${memberName}.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
183
arma/client/addons/org/ui/_site/logic/portalGetters.js
Normal file
183
arma/client/addons/org/ui/_site/logic/portalGetters.js
Normal file
@ -0,0 +1,183 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createPortalGetters = function createPortalGetters({
|
||||
portalData,
|
||||
session,
|
||||
}) {
|
||||
class OrgPortalGetters {
|
||||
formatCurrency(value) {
|
||||
return "$" + Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
formatVehicleType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatAssetType(type) {
|
||||
if (!type) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
formatDisplayName(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => {
|
||||
if (!part) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
part.charAt(0).toUpperCase() +
|
||||
part.slice(1).toLowerCase()
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
getAssetReadiness() {
|
||||
if (portalData.fleet.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = portalData.fleet.reduce(
|
||||
(sum, unit) => sum + (100 - parseInt(unit.damage, 10)),
|
||||
0,
|
||||
);
|
||||
return Math.round(total / portalData.fleet.length);
|
||||
}
|
||||
|
||||
getNormalizedRole() {
|
||||
return String(session.role || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
isDefaultOrg() {
|
||||
return (
|
||||
portalData.org.isDefault === true ||
|
||||
String(portalData.org.tag || "")
|
||||
.trim()
|
||||
.toUpperCase() === "DEFAULT"
|
||||
);
|
||||
}
|
||||
|
||||
isOrgOwner() {
|
||||
const ownerUid = String(
|
||||
portalData.org.ownerUid || portalData.org.owner || "",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ownerUid && actorUid) {
|
||||
return actorUid === ownerUid;
|
||||
}
|
||||
|
||||
return (
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isSessionCeo() {
|
||||
return session.ceo === true;
|
||||
}
|
||||
|
||||
isOrgLeaderOrCeo() {
|
||||
return (
|
||||
this.isOrgOwner() ||
|
||||
this.getNormalizedRole() === "LEADER" ||
|
||||
(this.isDefaultOrg() && this.isSessionCeo())
|
||||
);
|
||||
}
|
||||
|
||||
canManageMembers() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canManageTreasury() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canDisbandOrg() {
|
||||
return this.isOrgOwner() && !this.isDefaultOrg();
|
||||
}
|
||||
|
||||
canLeaveOrg() {
|
||||
return !this.isDefaultOrg() && !this.isOrgOwner();
|
||||
}
|
||||
|
||||
getMemberName(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.name || "");
|
||||
}
|
||||
|
||||
return String(member || "");
|
||||
}
|
||||
|
||||
getMemberUid(member) {
|
||||
if (member && typeof member === "object") {
|
||||
return String(member.uid || "");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
isOwnerMember(member) {
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isCurrentMember(member) {
|
||||
const memberUid = this.getMemberUid(member)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (memberUid && actorUid) {
|
||||
return memberUid === actorUid;
|
||||
}
|
||||
|
||||
return (
|
||||
this.getMemberName(member).trim().toLowerCase() ===
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isProtectedMember(member) {
|
||||
return (
|
||||
this.isOwnerMember(member) || this.isCurrentMember(member)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new OrgPortalGetters();
|
||||
};
|
||||
})();
|
||||
@ -1,79 +0,0 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createPortalPermissions = function createPortalPermissions({
|
||||
portalData,
|
||||
session,
|
||||
}) {
|
||||
class OrgPortalPermissions {
|
||||
getNormalizedRole() {
|
||||
return String(session.role || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
isDefaultOrg() {
|
||||
return (
|
||||
portalData.org.isDefault === true ||
|
||||
String(portalData.org.tag || "")
|
||||
.trim()
|
||||
.toUpperCase() === "DEFAULT"
|
||||
);
|
||||
}
|
||||
|
||||
isOrgOwner() {
|
||||
const ownerUid = String(
|
||||
portalData.org.ownerUid || portalData.org.owner || "",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const actorUid = String(session.actorUid || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ownerUid && actorUid) {
|
||||
return actorUid === ownerUid;
|
||||
}
|
||||
|
||||
return (
|
||||
String(session.actorName || "")
|
||||
.trim()
|
||||
.toLowerCase() ===
|
||||
String(portalData.org.owner || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
isSessionCeo() {
|
||||
return session.ceo === true;
|
||||
}
|
||||
|
||||
isOrgLeaderOrCeo() {
|
||||
return (
|
||||
this.isOrgOwner() ||
|
||||
this.getNormalizedRole() === "LEADER" ||
|
||||
(this.isDefaultOrg() && this.isSessionCeo())
|
||||
);
|
||||
}
|
||||
|
||||
canManageMembers() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canManageTreasury() {
|
||||
return this.isOrgLeaderOrCeo();
|
||||
}
|
||||
|
||||
canDisbandOrg() {
|
||||
return this.isOrgOwner() && !this.isDefaultOrg();
|
||||
}
|
||||
|
||||
canLeaveOrg() {
|
||||
return !this.isDefaultOrg() && !this.isOrgOwner();
|
||||
}
|
||||
}
|
||||
|
||||
return new OrgPortalPermissions();
|
||||
};
|
||||
})();
|
||||
@ -2,14 +2,14 @@
|
||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||
const { portalData } = OrgPortal.data;
|
||||
const store = OrgPortal.store;
|
||||
const permissions = OrgPortal.permissions;
|
||||
const getters = OrgPortal.getters;
|
||||
const registryStore = window.RegistryApp.store;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
OrgPortal.actions = SharedLogic.createPortalActions({
|
||||
portalData,
|
||||
store,
|
||||
permissions,
|
||||
getters,
|
||||
registryStore,
|
||||
});
|
||||
})();
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
const { portalData, session } = OrgPortal.data;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
OrgPortal.permissions = SharedLogic.createPortalPermissions({
|
||||
OrgPortal.getters = SharedLogic.createPortalGetters({
|
||||
portalData,
|
||||
session,
|
||||
});
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_store
|
||||
===================
|
||||
# forge_client_store
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
PREP(handleUIEvents);
|
||||
PREP(initStoreClass);
|
||||
PREP(initStoreUIBridge);
|
||||
PREP(openUI);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
#include "script_component.hpp"
|
||||
|
||||
call FUNC(initStoreClass);
|
||||
if (isNil QGVAR(StoreClass)) then { call FUNC(initStoreClass); };
|
||||
if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initStoreUIBridge); };
|
||||
|
||||
@ -32,6 +32,12 @@ diag_log format ["[FORGE:Client:Store] Handling UI event: %1 with data: %2", _ev
|
||||
|
||||
switch (_event) do {
|
||||
case "store::close": { closeDialog 1; };
|
||||
case "store::ready": {
|
||||
GVAR(StoreUIBridge) call ["handleReady", [_control]];
|
||||
};
|
||||
case "store::checkout::request": {
|
||||
GVAR(StoreUIBridge) call ["handleCheckoutRequest", [_data]];
|
||||
};
|
||||
default { hint format ["Unhandled UI event: %1", _event]; };
|
||||
};
|
||||
|
||||
|
||||
@ -27,11 +27,37 @@ GVAR(StoreClass) = createHashMapObject [[
|
||||
["#create", {
|
||||
_self set ["uid", getPlayerUID player];
|
||||
_self set ["store", createHashMap];
|
||||
_self set ["workspace", createHashMapFromArray [
|
||||
["budget", 48000],
|
||||
["availability", "Open"],
|
||||
["approval", "Field Access"],
|
||||
["moduleState", "Preview"],
|
||||
["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]
|
||||
]];
|
||||
_self set ["isLoaded", false];
|
||||
_self set ["lastSave", time];
|
||||
|
||||
systemChat format ["Store class initialized for %1", (name player)];
|
||||
diag_log "[FORGE:Client:Store] Store Class Initialized!";
|
||||
}],
|
||||
["buildUIPayload", {
|
||||
private _workspace = _self getOrDefault ["workspace", createHashMap];
|
||||
|
||||
createHashMapFromArray [
|
||||
["session", createHashMapFromArray [
|
||||
["actorName", name player],
|
||||
["actorUid", getPlayerUID player],
|
||||
["approvalRole", _workspace getOrDefault ["approval", "Field Access"]]
|
||||
]],
|
||||
["workspace", createHashMapFromArray [
|
||||
["budget", _workspace getOrDefault ["budget", 48000]],
|
||||
["availability", _workspace getOrDefault ["availability", "Open"]],
|
||||
["approval", _workspace getOrDefault ["approval", "Field Access"]],
|
||||
["moduleState", _workspace getOrDefault ["moduleState", "Preview"]],
|
||||
["searchTags", _workspace getOrDefault ["searchTags", ["Field", "Logistics", "Issued", "Restricted"]]]
|
||||
]],
|
||||
["cartItems", []]
|
||||
]
|
||||
}]
|
||||
]];
|
||||
|
||||
|
||||
89
arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf
Normal file
89
arma/client/addons/store/functions/fnc_initStoreUIBridge.sqf
Normal file
@ -0,0 +1,89 @@
|
||||
#include "..\script_component.hpp"
|
||||
|
||||
/*
|
||||
* File: fnc_initStoreUIBridge.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-03-10
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* Initializes the store UI bridge for browser control state and event routing.
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [
|
||||
["#type", "StoreUIBridgeBaseClass"],
|
||||
["getActiveBrowserControl", compileFinal {
|
||||
private _display = uiNamespace getVariable ["RscStore", displayNull];
|
||||
if (isNull _display) exitWith { controlNull };
|
||||
|
||||
_display displayCtrl 1004
|
||||
}],
|
||||
["execBridge", compileFinal {
|
||||
params [
|
||||
["_control", controlNull, [controlNull]],
|
||||
["_fnName", "", [""]],
|
||||
["_payload", createHashMap, [createHashMap]]
|
||||
];
|
||||
|
||||
if (isNull _control || { _fnName isEqualTo "" }) exitWith { false };
|
||||
|
||||
private _json = toJSON _payload;
|
||||
_control ctrlWebBrowserAction ["ExecJS", format ["StoreUIBridge.%1(%2)", _fnName, _json]];
|
||||
|
||||
true
|
||||
}],
|
||||
["sendBridgeEvent", compileFinal {
|
||||
params [
|
||||
["_event", "", [""]],
|
||||
["_data", createHashMap, [createHashMap]],
|
||||
["_control", controlNull, [controlNull]]
|
||||
];
|
||||
|
||||
if (_event isEqualTo "") exitWith { false };
|
||||
|
||||
private _targetControl = _control;
|
||||
if (isNull _targetControl) then {
|
||||
_targetControl = _self call ["getActiveBrowserControl", []];
|
||||
};
|
||||
|
||||
if (isNull _targetControl) exitWith { false };
|
||||
|
||||
_self call ["execBridge", [_targetControl, "receive", createHashMapFromArray [
|
||||
["event", _event],
|
||||
["data", _data]
|
||||
]]]
|
||||
}],
|
||||
["handleReady", compileFinal {
|
||||
params [["_control", controlNull, [controlNull]]];
|
||||
|
||||
private _payload = if (isNil QGVAR(StoreClass)) then {
|
||||
createHashMap
|
||||
} else {
|
||||
GVAR(StoreClass) call ["buildUIPayload", []]
|
||||
};
|
||||
|
||||
_self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]];
|
||||
}],
|
||||
["handleCheckoutRequest", compileFinal {
|
||||
params [["_data", createHashMap, [createHashMap]]];
|
||||
|
||||
private _items = _data getOrDefault ["items", []];
|
||||
private _message = format [
|
||||
"Checkout integration is not wired yet. Received %1 queued line(s).",
|
||||
count _items
|
||||
];
|
||||
|
||||
diag_log format [
|
||||
"[FORGE:Client:Store] Checkout request received: %1",
|
||||
_data
|
||||
];
|
||||
|
||||
_self call ["sendBridgeEvent", ["store::checkout::failure", createHashMapFromArray [
|
||||
["message", _message]
|
||||
]]];
|
||||
}]
|
||||
];
|
||||
|
||||
GVAR(StoreUIBridge) = createHashMapObject [GVAR(StoreUIBridgeBaseClass)];
|
||||
GVAR(StoreUIBridge)
|
||||
89
arma/client/addons/store/ui/_site/bridge.js
Normal file
89
arma/client/addons/store/ui/_site/bridge.js
Normal file
@ -0,0 +1,89 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const store = StorefrontApp.store;
|
||||
|
||||
function sendEvent(event, data) {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.SendAlert === "function"
|
||||
) {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
return sendEvent("store::close", {});
|
||||
}
|
||||
|
||||
function requestCheckout(payload) {
|
||||
return sendEvent("store::checkout::request", payload);
|
||||
}
|
||||
|
||||
function notifyReady() {
|
||||
return sendEvent("store::ready", { loaded: true });
|
||||
}
|
||||
|
||||
function receive(eventOrPayload, data = {}) {
|
||||
const event =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.event
|
||||
: eventOrPayload;
|
||||
const payloadData =
|
||||
typeof eventOrPayload === "object" && eventOrPayload !== null
|
||||
? eventOrPayload.data || {}
|
||||
: data;
|
||||
|
||||
if (event === "store::hydrate") {
|
||||
StorefrontApp.data.applyHydratePayload(payloadData);
|
||||
store.hydrateFromPayload(payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::checkout::success") {
|
||||
store.setIsCheckingOut(false);
|
||||
store.setCartItems([]);
|
||||
store.setCartOpen(false);
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"success",
|
||||
payloadData.message || "Checkout completed.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "store::checkout::failure") {
|
||||
store.setIsCheckingOut(false);
|
||||
if (StorefrontApp.actions) {
|
||||
StorefrontApp.actions.showNotice(
|
||||
"error",
|
||||
payloadData.message || "Checkout failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StorefrontApp.bridge = {
|
||||
sendEvent,
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
notifyReady,
|
||||
receive,
|
||||
};
|
||||
|
||||
window.StoreUIBridge = {
|
||||
requestClose,
|
||||
requestCheckout,
|
||||
notifyReady,
|
||||
receive,
|
||||
receiveHydrate: (data) => receive("store::hydrate", data),
|
||||
};
|
||||
})();
|
||||
@ -1,140 +1,750 @@
|
||||
(function () {
|
||||
const components = (window.StoreComponents = window.StoreComponents || {});
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { h, ensureScopedStyle } = StorefrontApp.runtime;
|
||||
const store = StorefrontApp.store;
|
||||
const getters = StorefrontApp.getters;
|
||||
const actions = StorefrontApp.actions;
|
||||
const { catalog, session, storeConfig } = StorefrontApp.data;
|
||||
const scopeAttr = "data-ui-store-app-shell";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const appShellCss = `
|
||||
${scopeSelector} {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--store-shell-bg);
|
||||
}
|
||||
|
||||
components.renderAppShell = function renderAppShell(options) {
|
||||
const { header, workspaceNavbar, workspaceBody, cartPanel } = options;
|
||||
${scopeSelector} .window-titlebar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem 0.95rem 1.2rem;
|
||||
background: var(--store-titlebar-bg);
|
||||
color: #f4f8fd;
|
||||
border-bottom: 1px solid var(--store-titlebar-border);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="store-shell">
|
||||
<div class="window-titlebar">
|
||||
<div class="window-titlebar-brand">
|
||||
<span class="window-titlebar-kicker">FORGE Logistics</span>
|
||||
<span class="window-titlebar-title">Supply Exchange</span>
|
||||
</div>
|
||||
<div class="window-titlebar-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="window-control-btn"
|
||||
disabled
|
||||
title="Minimize unavailable"
|
||||
aria-label="Minimize unavailable"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="window-control-btn"
|
||||
disabled
|
||||
title="Maximize unavailable"
|
||||
aria-label="Maximize unavailable"
|
||||
>
|
||||
[ ]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="window-control-btn is-close"
|
||||
id="store-close-btn"
|
||||
title="Close"
|
||||
aria-label="Close store interface"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${scopeSelector} .window-titlebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
<div class="store-app">
|
||||
<aside class="store-sidebar">
|
||||
<section class="module-card search-module">
|
||||
<div class="module-header">
|
||||
<div>
|
||||
<span class="eyebrow">Search</span>
|
||||
<h2 class="section-title">Inventory Search</h2>
|
||||
</div>
|
||||
<span class="pill">Module</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search inventory, classes, or suppliers"
|
||||
/>
|
||||
<div class="quick-tags">
|
||||
<span class="quick-tag">Field</span>
|
||||
<span class="quick-tag">Logistics</span>
|
||||
<span class="quick-tag">Issued</span>
|
||||
<span class="quick-tag">Restricted</span>
|
||||
</div>
|
||||
</section>
|
||||
${scopeSelector} .window-titlebar-kicker,
|
||||
${scopeSelector} .footer-title,
|
||||
${scopeSelector} .eyebrow {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--store-text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
<section class="module-card">
|
||||
<div class="module-header">
|
||||
<div>
|
||||
<span class="eyebrow">Filter</span>
|
||||
<h2 class="section-title">Procurement Filters</h2>
|
||||
</div>
|
||||
<span class="pill">Pending</span>
|
||||
</div>
|
||||
<div class="filter-stack">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Department</span>
|
||||
<div class="filter-value">
|
||||
<span>Operational Tier</span>
|
||||
<span class="filter-placeholder">Any</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Availability</span>
|
||||
<div class="filter-value">
|
||||
<span>Stock Window</span>
|
||||
<span class="filter-placeholder">Open</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Approval</span>
|
||||
<div class="filter-value">
|
||||
<span>Purchase Level</span>
|
||||
<span class="filter-placeholder">All</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
${scopeSelector} .window-titlebar-kicker {
|
||||
color: rgb(214 227 241 / 0.72);
|
||||
}
|
||||
|
||||
<main class="store-main">
|
||||
<section class="workspace-card">
|
||||
${workspaceNavbar}
|
||||
<div class="workspace-header">
|
||||
<div>
|
||||
<span class="eyebrow">${header.eyebrow}</span>
|
||||
<h1 class="section-title">${header.title}</h1>
|
||||
</div>
|
||||
<span class="pill">${header.badge}</span>
|
||||
</div>
|
||||
<div class="workspace-intro">
|
||||
<p class="section-copy">${header.copy}</p>
|
||||
<span class="inventory-ribbon">${header.ribbon}</span>
|
||||
</div>
|
||||
${workspaceBody}
|
||||
</section>
|
||||
${scopeSelector} .window-titlebar-title {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
${cartPanel}
|
||||
</main>
|
||||
</div>
|
||||
${scopeSelector} .window-titlebar-controls,
|
||||
${scopeSelector} .module-header,
|
||||
${scopeSelector} .store-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
<footer class="store-footer">
|
||||
<div class="footer-block">
|
||||
<span class="footer-title">Procurement Desk</span>
|
||||
<span class="footer-copy">Authorized supply browsing for personnel loadout preparation and mission staging.</span>
|
||||
</div>
|
||||
<div class="footer-block">
|
||||
<span class="footer-title">Catalog Scope</span>
|
||||
<span class="footer-copy">Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.</span>
|
||||
</div>
|
||||
<div class="footer-block">
|
||||
<span class="footer-title">Module State</span>
|
||||
<span class="footer-copy">Search, filters, and cart presentation are staged now. Purchase logic and item wiring will follow.</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
${scopeSelector} .window-control-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: 0.45rem;
|
||||
border: 1px solid rgb(197 220 243 / 0.16);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: rgb(237 244 251 / 0.88);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn.is-close {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
${scopeSelector} .window-control-btn.is-close:hover {
|
||||
background: rgb(185 67 67 / 0.9);
|
||||
border-color: rgb(255 222 222 / 0.45);
|
||||
}
|
||||
|
||||
${scopeSelector} .store-app {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: min(100%, 1613px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 308px minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-sidebar,
|
||||
${scopeSelector} .store-main {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
${scopeSelector} .module-card,
|
||||
${scopeSelector} .store-panel {
|
||||
background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);
|
||||
border: 1px solid var(--store-border);
|
||||
border-radius: 1.35rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .module-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-panel {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 1280px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
${scopeSelector} .module-header {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-panel-header {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
${scopeSelector} .section-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--store-text-main);
|
||||
}
|
||||
|
||||
${scopeSelector} .section-copy,
|
||||
${scopeSelector} .footer-copy {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
color: var(--store-text-muted);
|
||||
}
|
||||
|
||||
${scopeSelector} .pill {
|
||||
padding: 0.48rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: var(--store-accent-soft);
|
||||
color: var(--store-accent);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
${scopeSelector} .search-module {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .search-form {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .search-input {
|
||||
width: 100%;
|
||||
height: 2.9rem;
|
||||
padding: 0 0.95rem;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid var(--store-border);
|
||||
background: rgb(255 255 255 / 0.75);
|
||||
color: var(--store-text-main);
|
||||
}
|
||||
|
||||
${scopeSelector} .quick-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .quick-tag {
|
||||
padding: 0.55rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--store-border);
|
||||
background: rgb(255 255 255 / 0.52);
|
||||
color: var(--store-text-muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
${scopeSelector} .quick-tag.is-active {
|
||||
background: var(--store-accent-soft);
|
||||
color: var(--store-accent);
|
||||
border-color: rgb(18 54 93 / 0.2);
|
||||
}
|
||||
|
||||
${scopeSelector} .filter-stack {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .filter-group {
|
||||
padding: 0.95rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgb(255 255 255 / 0.48);
|
||||
border: 1px solid var(--store-border);
|
||||
}
|
||||
|
||||
${scopeSelector} .filter-label {
|
||||
display: block;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--store-text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .filter-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--store-text-main);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
${scopeSelector} .filter-placeholder {
|
||||
color: var(--store-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-panel-intro {
|
||||
padding: 0 1rem 1rem;
|
||||
border-bottom: 1px solid var(--store-accent-line);
|
||||
}
|
||||
|
||||
${scopeSelector} .store-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 1.25rem 1.15rem;
|
||||
border-top: 1px solid rgb(18 54 93 / 0.1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
${scopeSelector} .footer-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-toast-stack {
|
||||
position: fixed;
|
||||
top: 1.2rem;
|
||||
right: 1.5rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-toast {
|
||||
max-width: 24rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.9rem;
|
||||
border: 1px solid var(--store-border);
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 28px rgb(16 34 56 / 0.14);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-toast.is-success {
|
||||
background: #ecfdf5;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-toast.is-error {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
${scopeSelector} .store-app {
|
||||
grid-template-columns: 284px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
${scopeSelector} .store-app {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-sidebar,
|
||||
${scopeSelector} .store-main {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-main {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-toast-stack {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
StorefrontApp.components = StorefrontApp.components || {};
|
||||
StorefrontApp.componentFns = StorefrontApp.componentFns || {};
|
||||
|
||||
function WindowTitleBar() {
|
||||
return h(
|
||||
"div",
|
||||
{ className: "window-titlebar" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "window-titlebar-brand" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "window-titlebar-kicker" },
|
||||
"FORGE Logistics",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "window-titlebar-title" },
|
||||
"Supply Exchange",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "window-titlebar-controls" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn",
|
||||
disabled: true,
|
||||
title: "Minimize unavailable",
|
||||
"aria-label": "Minimize unavailable",
|
||||
},
|
||||
"-",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn",
|
||||
disabled: true,
|
||||
title: "Maximize unavailable",
|
||||
"aria-label": "Maximize unavailable",
|
||||
},
|
||||
"[ ]",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "window-control-btn is-close",
|
||||
title: "Close",
|
||||
"aria-label": "Close store interface",
|
||||
onClick: () => actions.closeStore(),
|
||||
},
|
||||
"X",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderStoreBody(state) {
|
||||
const {
|
||||
CategoryCard,
|
||||
SubcategoryCard,
|
||||
ProductCard,
|
||||
EmptyStateCard,
|
||||
CategoryGrid,
|
||||
SubcategoryGrid,
|
||||
ProductGrid,
|
||||
} = StorefrontApp.componentFns;
|
||||
|
||||
if (state.view === "weapons" || state.view === "vehicles") {
|
||||
const slotType = state.view === "vehicles" ? "vehicle" : "weapon";
|
||||
const items = getters.getVisibleSubcategoryCards(state, catalog);
|
||||
|
||||
return SubcategoryGrid(
|
||||
items.length > 0
|
||||
? items.map((category) =>
|
||||
SubcategoryCard(category, slotType),
|
||||
)
|
||||
: EmptyStateCard({
|
||||
title: "No matching slots",
|
||||
copy: "Try a different search query or clear the current filter.",
|
||||
actionLabel: "Clear Search",
|
||||
onAction: () => actions.clearSearch(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.view === "items") {
|
||||
const items = getters.getVisibleItems(state, catalog);
|
||||
const quantityByCode = state.cartItems.reduce((acc, item) => {
|
||||
acc[item.code] = item.quantity;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return ProductGrid(
|
||||
items.length > 0
|
||||
? items.map((item) =>
|
||||
ProductCard(item, quantityByCode[item.code] || 0),
|
||||
)
|
||||
: EmptyStateCard({
|
||||
title: "No matching products",
|
||||
copy: "Your search filter excluded the available preview items for this category.",
|
||||
actionLabel: "Clear Search",
|
||||
onAction: () => actions.clearSearch(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const items = getters.getVisibleCategoryCards(state, catalog);
|
||||
return CategoryGrid(
|
||||
items.length > 0
|
||||
? items.map((category) => CategoryCard(category))
|
||||
: EmptyStateCard({
|
||||
title: "No matching departments",
|
||||
copy: "Your search filter excluded every top-level department.",
|
||||
actionLabel: "Clear Search",
|
||||
onAction: () => actions.clearSearch(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
StorefrontApp.components.App = function App() {
|
||||
const Navbar = StorefrontApp.componentFns.Navbar;
|
||||
const Cart = StorefrontApp.componentFns.Cart;
|
||||
const state = getters.getStoreState(store);
|
||||
const header = getters.getStoreHeader(state);
|
||||
const notice = store.getNotice();
|
||||
const activeQuery = state.searchQuery;
|
||||
const filterDepartment =
|
||||
state.view === "items"
|
||||
? actions.formatTitle(
|
||||
getters.getSelectionKey(state) || "Catalog",
|
||||
)
|
||||
: actions.formatTitle(state.view);
|
||||
|
||||
ensureScopedStyle("storefront-app-shell", appShellCss);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ [scopeAttr]: "" },
|
||||
WindowTitleBar(),
|
||||
notice.text
|
||||
? h(
|
||||
"div",
|
||||
{ className: "store-toast-stack" },
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
notice.type === "error"
|
||||
? "store-toast is-error"
|
||||
: "store-toast is-success",
|
||||
},
|
||||
notice.text,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
h(
|
||||
"div",
|
||||
{ className: "store-app" },
|
||||
h(
|
||||
"aside",
|
||||
{ className: "store-sidebar" },
|
||||
h(
|
||||
"section",
|
||||
{ className: "module-card search-module" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "module-header" },
|
||||
h(
|
||||
"div",
|
||||
null,
|
||||
h("span", { className: "eyebrow" }, "Search"),
|
||||
h(
|
||||
"h2",
|
||||
{ className: "section-title" },
|
||||
"Inventory Search",
|
||||
),
|
||||
),
|
||||
h("span", { className: "pill" }, "Live"),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "search-form" },
|
||||
h("input", {
|
||||
id: "store-search-input",
|
||||
type: "text",
|
||||
className: "search-input",
|
||||
placeholder:
|
||||
"Search inventory, classes, or suppliers",
|
||||
value: activeQuery,
|
||||
}),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
gap: "0.65rem",
|
||||
},
|
||||
},
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
"store-btn store-btn-primary",
|
||||
onClick: () =>
|
||||
actions.applySearchQuery(
|
||||
document.getElementById(
|
||||
"store-search-input",
|
||||
)?.value || "",
|
||||
),
|
||||
},
|
||||
"Apply Search",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
"store-btn store-btn-secondary",
|
||||
onClick: () => actions.clearSearch(),
|
||||
},
|
||||
"Clear",
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "quick-tags" },
|
||||
(storeConfig.searchTags || []).map((tag) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
activeQuery === tag
|
||||
? "quick-tag is-active"
|
||||
: "quick-tag",
|
||||
onClick: () =>
|
||||
actions.applySearchQuery(tag),
|
||||
},
|
||||
tag,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"section",
|
||||
{ className: "module-card" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "module-header" },
|
||||
h(
|
||||
"div",
|
||||
null,
|
||||
h("span", { className: "eyebrow" }, "Filter"),
|
||||
h(
|
||||
"h2",
|
||||
{ className: "section-title" },
|
||||
"Procurement Filters",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "pill" },
|
||||
storeConfig.moduleState,
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-stack" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-group" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-label" },
|
||||
"Department",
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Operational Tier"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
filterDepartment,
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-group" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-label" },
|
||||
"Availability",
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Stock Window"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
storeConfig.availability,
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-group" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-label" },
|
||||
"Approval",
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "filter-value" },
|
||||
h("span", null, "Purchase Level"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "filter-placeholder" },
|
||||
session.approvalRole ||
|
||||
storeConfig.approval,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"main",
|
||||
{ className: "store-main" },
|
||||
h(
|
||||
"section",
|
||||
{ className: "store-panel" },
|
||||
Navbar(),
|
||||
h(
|
||||
"div",
|
||||
{ className: "store-panel-header" },
|
||||
h(
|
||||
"div",
|
||||
null,
|
||||
h(
|
||||
"span",
|
||||
{ className: "eyebrow" },
|
||||
header.eyebrow,
|
||||
),
|
||||
h(
|
||||
"h1",
|
||||
{ className: "section-title" },
|
||||
header.title,
|
||||
),
|
||||
),
|
||||
h("span", { className: "pill" }, header.badge),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "store-panel-intro" },
|
||||
h("p", { className: "section-copy" }, header.copy),
|
||||
),
|
||||
renderStoreBody(state),
|
||||
),
|
||||
Cart(),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"footer",
|
||||
{ className: "store-footer" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "footer-block" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "footer-title" },
|
||||
"Procurement Desk",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "footer-copy" },
|
||||
"Authorized supply browsing for personnel loadout preparation and mission staging.",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "footer-block" },
|
||||
h("span", { className: "footer-title" }, "Catalog Scope"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "footer-copy" },
|
||||
"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "footer-block" },
|
||||
h("span", { className: "footer-title" }, "Bridge State"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "footer-copy" },
|
||||
session.actorName
|
||||
? `Hydrated for ${session.actorName}. Checkout remains a stub until the procurement backend is wired.`
|
||||
: "The browser bridge is active. Hydration and checkout events now flow through the same contract shape as the org UI.",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
})();
|
||||
|
||||
@ -1,39 +1,335 @@
|
||||
(function () {
|
||||
const components = (window.StoreComponents = window.StoreComponents || {});
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { h, ensureScopedStyle } = StorefrontApp.runtime;
|
||||
const actions = StorefrontApp.actions;
|
||||
const scopeAttr = "data-ui-store-cards";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const cardsCss = `
|
||||
${scopeSelector} .catalog-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
components.createCategoryCard = function createCategoryCard(category) {
|
||||
return `
|
||||
<button class="card-button category-card" type="button" data-category="${category.id}">
|
||||
<span class="card-label">${category.label}</span>
|
||||
</button>
|
||||
`;
|
||||
${scopeSelector} .catalog-grid::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-grid::-webkit-scrollbar-track {
|
||||
background: rgb(255 255 255 / 0.45);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-grid::-webkit-scrollbar-thumb {
|
||||
background: rgb(120 136 155 / 0.9);
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-grid.is-categories,
|
||||
${scopeSelector} .catalog-grid.is-products {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
${scopeSelector} .catalog-grid.is-subcategories {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
${scopeSelector} .card-button,
|
||||
${scopeSelector} .product-card,
|
||||
${scopeSelector} .empty-state {
|
||||
border: 1px solid var(--store-border);
|
||||
border-radius: 1.15rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(226 233 239 / 0.9) 100%),
|
||||
var(--store-surface-strong);
|
||||
color: var(--store-accent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.8),
|
||||
0 10px 24px rgb(16 34 56 / 0.06);
|
||||
}
|
||||
|
||||
${scopeSelector} .card-button {
|
||||
min-height: 12.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.35rem;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
${scopeSelector} .card-button:hover,
|
||||
${scopeSelector} .product-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgb(18 54 93 / 0.32);
|
||||
box-shadow:
|
||||
0 16px 28px rgb(16 34 56 / 0.11),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.88);
|
||||
}
|
||||
|
||||
${scopeSelector} .card-kicker,
|
||||
${scopeSelector} .product-code,
|
||||
${scopeSelector} .empty-state-kicker {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--store-text-subtle);
|
||||
}
|
||||
|
||||
${scopeSelector} .card-label {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
${scopeSelector} .card-copy,
|
||||
${scopeSelector} .product-copy,
|
||||
${scopeSelector} .empty-state-copy {
|
||||
margin: 0;
|
||||
color: var(--store-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-card {
|
||||
min-height: 20rem;
|
||||
padding: 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-image {
|
||||
height: 9.5rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px dashed rgb(18 54 93 / 0.24);
|
||||
background: linear-gradient(135deg, rgb(235 240 245) 0%, rgb(221 228 235) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--store-text-subtle);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--store-text-main);
|
||||
}
|
||||
|
||||
${scopeSelector} .product-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .product-price {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--store-success);
|
||||
}
|
||||
|
||||
${scopeSelector} .product-qty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
border-radius: 999px;
|
||||
background: var(--store-accent-soft);
|
||||
color: var(--store-accent);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .empty-state {
|
||||
padding: 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
${scopeSelector} .catalog-grid.is-categories,
|
||||
${scopeSelector} .catalog-grid.is-products {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
${scopeSelector} .catalog-grid.is-categories,
|
||||
${scopeSelector} .catalog-grid.is-subcategories,
|
||||
${scopeSelector} .catalog-grid.is-products {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
StorefrontApp.componentFns = StorefrontApp.componentFns || {};
|
||||
|
||||
function createGrid(className, children) {
|
||||
ensureScopedStyle("storefront-cards", cardsCss);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ [scopeAttr]: "" },
|
||||
h("div", { className: `catalog-grid ${className}` }, children),
|
||||
);
|
||||
}
|
||||
|
||||
StorefrontApp.componentFns.CategoryCard = function CategoryCard(category) {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "card-button",
|
||||
onClick: () => actions.selectCategory(category.id),
|
||||
},
|
||||
h("span", { className: "card-kicker" }, "Department"),
|
||||
h("strong", { className: "card-label" }, category.label),
|
||||
h(
|
||||
"p",
|
||||
{ className: "card-copy" },
|
||||
"Open this department and move into staged inventory browsing.",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
components.createSubCategoryCard = function createSubCategoryCard(
|
||||
StorefrontApp.componentFns.SubcategoryCard = function SubcategoryCard(
|
||||
category,
|
||||
slotType,
|
||||
) {
|
||||
return `
|
||||
<button class="card-button subcategory-card" type="button" data-subcategory="${category.id}" data-subcategory-type="${slotType}">
|
||||
<span class="card-label">${category.label}</span>
|
||||
</button>
|
||||
`;
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "card-button",
|
||||
onClick: () => actions.selectSubcategory(category.id, slotType),
|
||||
},
|
||||
h(
|
||||
"span",
|
||||
{ className: "card-kicker" },
|
||||
slotType === "vehicle" ? "Vehicle Class" : "Weapon Slot",
|
||||
),
|
||||
h("strong", { className: "card-label" }, category.label),
|
||||
h(
|
||||
"p",
|
||||
{ className: "card-copy" },
|
||||
"Open the next tier and review product previews for this selection.",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
components.createProductCard = function createProductCard(item) {
|
||||
return `
|
||||
<article class="card-button product-card">
|
||||
<div class="product-image">Image Placeholder</div>
|
||||
<div class="product-meta">
|
||||
<span class="product-code">${item.code}</span>
|
||||
<strong class="product-name">${item.name}</strong>
|
||||
</div>
|
||||
<p class="product-copy">${item.description}</p>
|
||||
<div class="product-footer">
|
||||
<span class="product-price">${item.price}</span>
|
||||
<button class="action-btn" type="button">Add to Cart</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
StorefrontApp.componentFns.ProductCard = function ProductCard(
|
||||
item,
|
||||
quantityInCart,
|
||||
) {
|
||||
return h(
|
||||
"article",
|
||||
{ className: "product-card" },
|
||||
h("div", { className: "product-image" }, "Image Placeholder"),
|
||||
h(
|
||||
"div",
|
||||
{ className: "product-meta" },
|
||||
h("span", { className: "product-code" }, item.code),
|
||||
h("strong", { className: "product-name" }, item.name),
|
||||
),
|
||||
h("p", { className: "product-copy" }, item.description),
|
||||
h(
|
||||
"div",
|
||||
{ className: "product-footer" },
|
||||
h("span", { className: "product-price" }, item.price),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.55rem",
|
||||
},
|
||||
},
|
||||
quantityInCart > 0
|
||||
? h(
|
||||
"span",
|
||||
{ className: "product-qty" },
|
||||
quantityInCart,
|
||||
)
|
||||
: null,
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-primary",
|
||||
onClick: () => actions.addToCart(item),
|
||||
},
|
||||
"Add to Cart",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
StorefrontApp.componentFns.EmptyStateCard = function EmptyStateCard({
|
||||
title,
|
||||
copy,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) {
|
||||
return h(
|
||||
"article",
|
||||
{ className: "empty-state" },
|
||||
h("span", { className: "empty-state-kicker" }, "No Results"),
|
||||
h("strong", { className: "card-label" }, title),
|
||||
h("p", { className: "empty-state-copy" }, copy),
|
||||
actionLabel && typeof onAction === "function"
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-secondary",
|
||||
onClick: onAction,
|
||||
},
|
||||
actionLabel,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
StorefrontApp.componentFns.CategoryGrid = function CategoryGrid(children) {
|
||||
return createGrid("is-categories", children);
|
||||
};
|
||||
|
||||
StorefrontApp.componentFns.SubcategoryGrid = function SubcategoryGrid(
|
||||
children,
|
||||
) {
|
||||
return createGrid("is-subcategories", children);
|
||||
};
|
||||
|
||||
StorefrontApp.componentFns.ProductGrid = function ProductGrid(children) {
|
||||
return createGrid("is-products", children);
|
||||
};
|
||||
})();
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
(function () {
|
||||
const components = (window.StoreComponents = window.StoreComponents || {});
|
||||
|
||||
components.renderCartPanel = function renderCartPanel(state) {
|
||||
return `
|
||||
<div class="cart-overlay ${state.cartOpen ? "is-open" : ""}" aria-hidden="${state.cartOpen ? "false" : "true"}">
|
||||
<button
|
||||
type="button"
|
||||
class="cart-overlay-backdrop"
|
||||
id="store-cart-backdrop"
|
||||
aria-label="Close cart"
|
||||
></button>
|
||||
|
||||
<aside class="store-cart-panel">
|
||||
<section class="cart-card">
|
||||
<div class="cart-header">
|
||||
<div>
|
||||
<span class="eyebrow">Cart</span>
|
||||
<h2 class="section-title">Acquisition Queue</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="window-control-btn cart-panel-close"
|
||||
id="store-cart-close-btn"
|
||||
aria-label="Close cart"
|
||||
title="Close cart"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cart-status">
|
||||
<span class="eyebrow">Status</span>
|
||||
<p class="section-copy">
|
||||
Cart wiring is intentionally deferred. This panel is laid out for order lines, approval totals, and checkout actions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="cart-kpi">
|
||||
<div class="cart-kpi-card">
|
||||
<span class="kpi-label">Lines</span>
|
||||
<span class="kpi-value">0</span>
|
||||
</div>
|
||||
<div class="cart-kpi-card">
|
||||
<span class="kpi-label">Budget</span>
|
||||
<span class="kpi-value">$48,000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-lines">
|
||||
<div class="cart-line">
|
||||
<div class="cart-line-title">Cart Placeholder A</div>
|
||||
<div class="cart-line-meta">Awaiting selection and quantity logic</div>
|
||||
</div>
|
||||
<div class="cart-line">
|
||||
<div class="cart-line-title">Cart Placeholder B</div>
|
||||
<div class="cart-line-meta">Reserved for grouped order summaries</div>
|
||||
</div>
|
||||
<div class="cart-line">
|
||||
<div class="cart-line-title">Cart Placeholder C</div>
|
||||
<div class="cart-line-meta">Reserved for checkout validation status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-summary">
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Subtotal</span>
|
||||
<span class="summary-value">$0</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Fees</span>
|
||||
<span class="summary-value">$0</span>
|
||||
</div>
|
||||
<div class="summary-row total">
|
||||
<span class="summary-label">Total</span>
|
||||
<span class="summary-value">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-actions">
|
||||
<button type="button" class="action-btn">Review Cart</button>
|
||||
<button type="button" class="action-btn muted-btn">Checkout Locked</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
})();
|
||||
486
arma/client/addons/store/ui/_site/components/cart.js
Normal file
486
arma/client/addons/store/ui/_site/components/cart.js
Normal file
@ -0,0 +1,486 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { h, ensureScopedStyle } = StorefrontApp.runtime;
|
||||
const store = StorefrontApp.store;
|
||||
const getters = StorefrontApp.getters;
|
||||
const actions = StorefrontApp.actions;
|
||||
const { storeConfig } = StorefrontApp.data;
|
||||
const scopeAttr = "data-ui-store-cart";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const cartCss = `
|
||||
${scopeSelector} {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
${scopeSelector}.is-open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
${scopeSelector} .store-cart {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: min(24rem, calc(100% - 1rem));
|
||||
transform: translateX(calc(100% + 1rem));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
${scopeSelector}.is-open .store-cart {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
border: 1px solid var(--store-border);
|
||||
background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);
|
||||
box-shadow:
|
||||
0 18px 40px rgb(11 27 46 / 0.16),
|
||||
0 4px 12px rgb(11 27 46 / 0.08);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-close {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-status,
|
||||
${scopeSelector} .cart-kpi-card,
|
||||
${scopeSelector} .cart-line {
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.58);
|
||||
border: 1px solid var(--store-border);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-status,
|
||||
${scopeSelector} .cart-kpi-card,
|
||||
${scopeSelector} .cart-line {
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-kpi {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .kpi-label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--store-text-subtle);
|
||||
}
|
||||
|
||||
${scopeSelector} .kpi-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-lines {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-lines::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-lines::-webkit-scrollbar-track {
|
||||
background: rgb(255 255 255 / 0.55);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-lines::-webkit-scrollbar-thumb {
|
||||
background: rgb(120 136 155 / 0.9);
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-line-top,
|
||||
${scopeSelector} .cart-line-controls,
|
||||
${scopeSelector} .summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-line-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-line-code {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--store-text-subtle);
|
||||
}
|
||||
|
||||
${scopeSelector} .qty-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .qty-badge {
|
||||
min-width: 1.9rem;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .qty-btn,
|
||||
${scopeSelector} .remove-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-summary {
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--store-accent-line);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .summary-row.total {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .summary-label,
|
||||
${scopeSelector} .cart-line-meta {
|
||||
color: var(--store-text-muted);
|
||||
}
|
||||
|
||||
${scopeSelector} .summary-value {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .summary-actions {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-empty {
|
||||
padding: 1rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px dashed var(--store-border);
|
||||
color: var(--store-text-muted);
|
||||
background: rgb(255 255 255 / 0.38);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
${scopeSelector} .store-cart {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(24rem, 100%);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
StorefrontApp.componentFns = StorefrontApp.componentFns || {};
|
||||
|
||||
StorefrontApp.componentFns.Cart = function Cart() {
|
||||
const state = getters.getStoreState(store);
|
||||
const summary = getters.summarizeCart(state.cartItems);
|
||||
const remainingBudget = Math.max(
|
||||
0,
|
||||
Number(storeConfig.budget || 0) - summary.total,
|
||||
);
|
||||
|
||||
ensureScopedStyle("storefront-cart", cartCss);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
className: state.cartOpen ? "is-open" : "",
|
||||
[scopeAttr]: "",
|
||||
"aria-hidden": state.cartOpen ? "false" : "true",
|
||||
},
|
||||
h(
|
||||
"aside",
|
||||
{ className: "store-cart" },
|
||||
h(
|
||||
"section",
|
||||
{ className: "cart-card" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-header" },
|
||||
h(
|
||||
"div",
|
||||
null,
|
||||
h("span", { className: "eyebrow" }, "Cart"),
|
||||
h(
|
||||
"h2",
|
||||
{ className: "section-title" },
|
||||
"Acquisition Queue",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
"window-control-btn cart-close is-close",
|
||||
"aria-label": "Close cart",
|
||||
title: "Close cart",
|
||||
onClick: () => actions.closeCart(),
|
||||
},
|
||||
"X",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-status" },
|
||||
h("span", { className: "eyebrow" }, "Status"),
|
||||
h(
|
||||
"p",
|
||||
{ className: "section-copy" },
|
||||
state.isCheckingOut
|
||||
? "Checkout request sent through the browser bridge."
|
||||
: "Local cart state is active. Checkout is routed through the same bridge contract used by the org UI.",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-kpi" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-kpi-card" },
|
||||
h("span", { className: "kpi-label" }, "Lines"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "kpi-value" },
|
||||
summary.lineCount,
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-kpi-card" },
|
||||
h("span", { className: "kpi-label" }, "Budget"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "kpi-value" },
|
||||
getters.formatCurrency(storeConfig.budget),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-lines" },
|
||||
summary.lineCount > 0
|
||||
? state.cartItems.map((item) =>
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-line" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-line-top" },
|
||||
h(
|
||||
"div",
|
||||
null,
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
"cart-line-code",
|
||||
},
|
||||
item.code,
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className:
|
||||
"cart-line-title",
|
||||
},
|
||||
item.name,
|
||||
),
|
||||
),
|
||||
h(
|
||||
"strong",
|
||||
null,
|
||||
getters.formatCurrency(
|
||||
getters.parsePrice(
|
||||
item.price,
|
||||
) * item.quantity,
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-line-controls" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "qty-controls" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
"store-btn store-btn-secondary qty-btn",
|
||||
onClick: () =>
|
||||
actions.decrementCartItem(
|
||||
item.code,
|
||||
),
|
||||
},
|
||||
"-",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "qty-badge" },
|
||||
item.quantity,
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
"store-btn store-btn-secondary qty-btn",
|
||||
onClick: () =>
|
||||
actions.incrementCartItem(
|
||||
item.code,
|
||||
),
|
||||
},
|
||||
"+",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className:
|
||||
"store-btn store-btn-secondary remove-btn",
|
||||
onClick: () =>
|
||||
actions.removeCartItem(
|
||||
item.code,
|
||||
),
|
||||
},
|
||||
"Remove",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: h(
|
||||
"div",
|
||||
{ className: "cart-empty" },
|
||||
"No items are queued yet. Add products from the catalog to build a checkout payload.",
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "cart-summary" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "summary-row" },
|
||||
h("span", { className: "summary-label" }, "Items"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-value" },
|
||||
summary.itemCount,
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "summary-row" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-label" },
|
||||
"Subtotal",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-value" },
|
||||
getters.formatCurrency(summary.subtotal),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "summary-row" },
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-label" },
|
||||
"Remaining Budget",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-value" },
|
||||
getters.formatCurrency(remainingBudget),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "summary-row total" },
|
||||
h("span", { className: "summary-label" }, "Total"),
|
||||
h(
|
||||
"span",
|
||||
{ className: "summary-value" },
|
||||
getters.formatCurrency(summary.total),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "summary-actions" },
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-secondary",
|
||||
onClick: () => actions.closeCart(),
|
||||
},
|
||||
"Review Later",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-btn store-btn-primary",
|
||||
disabled:
|
||||
summary.lineCount === 0 ||
|
||||
state.isCheckingOut,
|
||||
onClick: () => actions.requestCheckout(),
|
||||
},
|
||||
state.isCheckingOut
|
||||
? "Submitting Request..."
|
||||
: "Submit Checkout",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
})();
|
||||
189
arma/client/addons/store/ui/_site/components/navbar.js
Normal file
189
arma/client/addons/store/ui/_site/components/navbar.js
Normal file
@ -0,0 +1,189 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { h, ensureScopedStyle } = StorefrontApp.runtime;
|
||||
const getters = StorefrontApp.getters;
|
||||
const store = StorefrontApp.store;
|
||||
const actions = StorefrontApp.actions;
|
||||
const scopeAttr = "data-ui-store-navbar";
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const navbarCss = `
|
||||
${scopeSelector} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 0.95rem;
|
||||
border-bottom: 1px solid var(--store-accent-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%),
|
||||
linear-gradient(180deg, rgb(236 241 246 / 0.52) 0%, rgb(245 243 239 / 0.2) 100%);
|
||||
}
|
||||
|
||||
${scopeSelector} .store-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
${scopeSelector} .breadcrumb-link,
|
||||
${scopeSelector} .breadcrumb-current,
|
||||
${scopeSelector} .breadcrumb-separator {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
${scopeSelector} .breadcrumb-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--store-text-subtle);
|
||||
}
|
||||
|
||||
${scopeSelector} .breadcrumb-link:hover {
|
||||
color: var(--store-accent);
|
||||
}
|
||||
|
||||
${scopeSelector} .breadcrumb-current {
|
||||
color: var(--store-accent);
|
||||
}
|
||||
|
||||
${scopeSelector} .breadcrumb-separator {
|
||||
color: rgb(124 138 155 / 0.72);
|
||||
}
|
||||
|
||||
${scopeSelector} .store-cart-btn {
|
||||
position: relative;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid var(--store-border-strong);
|
||||
background: rgb(255 255 255 / 0.68);
|
||||
color: var(--store-accent);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75);
|
||||
}
|
||||
|
||||
${scopeSelector} .store-cart-btn:hover {
|
||||
background: rgb(219 231 243 / 0.88);
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-toggle-icon {
|
||||
position: relative;
|
||||
width: 0.95rem;
|
||||
height: 0.8rem;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 0.16rem 0.16rem 0.24rem 0.24rem;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-toggle-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -0.34rem;
|
||||
left: 0.2rem;
|
||||
width: 0.5rem;
|
||||
height: 0.3rem;
|
||||
border: 1.5px solid currentColor;
|
||||
border-bottom: 0;
|
||||
border-radius: 0.35rem 0.35rem 0 0;
|
||||
}
|
||||
|
||||
${scopeSelector} .cart-count {
|
||||
position: absolute;
|
||||
top: -0.35rem;
|
||||
right: -0.35rem;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--store-accent);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
${scopeSelector} {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
StorefrontApp.componentFns = StorefrontApp.componentFns || {};
|
||||
|
||||
StorefrontApp.componentFns.Navbar = function Navbar() {
|
||||
const state = getters.getStoreState(store);
|
||||
const items = getters.getStoreBreadcrumbs(state);
|
||||
const cartSummary = getters.summarizeCart(state.cartItems);
|
||||
|
||||
ensureScopedStyle("storefront-navbar", navbarCss);
|
||||
|
||||
return h(
|
||||
"nav",
|
||||
{ [scopeAttr]: "" },
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "store-breadcrumbs",
|
||||
"aria-label": "Store navigation",
|
||||
},
|
||||
items.map((item, index) => {
|
||||
const isCurrent = index === items.length - 1;
|
||||
|
||||
if (isCurrent) {
|
||||
return h(
|
||||
"span",
|
||||
{ className: "breadcrumb-current" },
|
||||
item.label,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "breadcrumb-link",
|
||||
onClick: () =>
|
||||
actions.navigateToBreadcrumb(item.id),
|
||||
},
|
||||
item.label,
|
||||
),
|
||||
h("span", { className: "breadcrumb-separator" }, "/"),
|
||||
];
|
||||
}),
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "store-cart-btn",
|
||||
onClick: () => actions.toggleCart(),
|
||||
title: state.cartOpen ? "Close cart" : "Open cart",
|
||||
"aria-label": state.cartOpen ? "Close cart" : "Open cart",
|
||||
},
|
||||
h("span", {
|
||||
className: "cart-toggle-icon",
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
cartSummary.itemCount > 0
|
||||
? h(
|
||||
"span",
|
||||
{ className: "cart-count" },
|
||||
cartSummary.itemCount,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
};
|
||||
})();
|
||||
@ -1,110 +0,0 @@
|
||||
(function () {
|
||||
const components = (window.StoreComponents = window.StoreComponents || {});
|
||||
|
||||
components.getBreadcrumbItems = function getBreadcrumbItems(
|
||||
state,
|
||||
formatTitle,
|
||||
) {
|
||||
const items = [{ id: "categories", label: "Supply Exchange" }];
|
||||
|
||||
if (state.view === "weapons") {
|
||||
items.push({ id: "weapons", label: "Weapons" });
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.view === "vehicles") {
|
||||
items.push({ id: "vehicles", label: "Vehicles" });
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.view === "items") {
|
||||
if (state.selectedWeaponSlot) {
|
||||
items.push({ id: "weapons", label: "Weapons" });
|
||||
items.push({
|
||||
id: "weapon-slot",
|
||||
label: formatTitle(state.selectedWeaponSlot),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.selectedVehicleSlot) {
|
||||
items.push({ id: "vehicles", label: "Vehicles" });
|
||||
items.push({
|
||||
id: "vehicle-slot",
|
||||
label: formatTitle(state.selectedVehicleSlot),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.selectedCategory) {
|
||||
items.push({
|
||||
id: "category",
|
||||
label: formatTitle(state.selectedCategory),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
components.renderWorkspaceBreadcrumbs = function renderWorkspaceBreadcrumbs(
|
||||
state,
|
||||
formatTitle,
|
||||
) {
|
||||
const items = components.getBreadcrumbItems(state, formatTitle);
|
||||
|
||||
return `
|
||||
<div class="workspace-breadcrumbs" aria-label="Breadcrumb">
|
||||
${items
|
||||
.map((item, index) => {
|
||||
const isCurrent = index === items.length - 1;
|
||||
|
||||
if (isCurrent) {
|
||||
return `
|
||||
<span class="breadcrumb-current">${item.label}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="breadcrumb-link"
|
||||
data-breadcrumb-target="${item.id}"
|
||||
>
|
||||
${item.label}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('<span class="breadcrumb-separator">/</span>')}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
components.renderWorkspaceCartToggle = function renderWorkspaceCartToggle(
|
||||
state,
|
||||
) {
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="workspace-cart-btn"
|
||||
id="store-cart-toggle-btn"
|
||||
aria-label="${state.cartOpen ? "Close cart" : "Open cart"}"
|
||||
title="${state.cartOpen ? "Close cart" : "Open cart"}"
|
||||
>
|
||||
<span class="cart-toggle-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
`;
|
||||
};
|
||||
|
||||
components.renderWorkspaceNavbar = function renderWorkspaceNavbar(
|
||||
state,
|
||||
formatTitle,
|
||||
) {
|
||||
return `
|
||||
<nav class="workspace-navbar" aria-label="Store navigation">
|
||||
${components.renderWorkspaceBreadcrumbs(state, formatTitle)}
|
||||
${components.renderWorkspaceCartToggle(state)}
|
||||
</nav>
|
||||
`;
|
||||
};
|
||||
})();
|
||||
@ -1,5 +1,30 @@
|
||||
(function () {
|
||||
window.StoreData = {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
|
||||
const defaultSession = {
|
||||
actorName: "",
|
||||
actorUid: "",
|
||||
approvalRole: "Field Access",
|
||||
};
|
||||
|
||||
const defaultStoreConfig = {
|
||||
budget: 48000,
|
||||
availability: "Open",
|
||||
approval: "Field Access",
|
||||
moduleState: "Preview",
|
||||
searchTags: ["Field", "Logistics", "Issued", "Restricted"],
|
||||
};
|
||||
|
||||
function cloneValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function replaceObject(target, source) {
|
||||
Object.keys(target).forEach((key) => delete target[key]);
|
||||
Object.assign(target, cloneValue(source));
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
categoryCards: [
|
||||
{ id: "uniforms", label: "Uniforms" },
|
||||
{ id: "headgear", label: "Headgear" },
|
||||
@ -371,4 +396,24 @@
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
StorefrontApp.data = {
|
||||
catalog,
|
||||
session: Object.assign({}, defaultSession),
|
||||
storeConfig: Object.assign({}, defaultStoreConfig),
|
||||
applyHydratePayload(payload) {
|
||||
replaceObject(
|
||||
this.session,
|
||||
Object.assign({}, defaultSession, payload?.session || {}),
|
||||
);
|
||||
replaceObject(
|
||||
this.storeConfig,
|
||||
Object.assign(
|
||||
{},
|
||||
defaultStoreConfig,
|
||||
payload?.workspace || payload?.storeConfig || {},
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@ -8,14 +8,17 @@
|
||||
const addonRoot = "forge\\forge_client\\addons\\store\\ui\\_site\\";
|
||||
const styleFiles = ["style.css"];
|
||||
const scriptFiles = [
|
||||
"runtime.js",
|
||||
"data.js",
|
||||
"logic/store.js",
|
||||
"pages/StoreView.js",
|
||||
"useStore.js",
|
||||
"bridge.js",
|
||||
"logic/events.js",
|
||||
"components/AppShell.js",
|
||||
"components/cards.js",
|
||||
"components/cart-panel.js",
|
||||
"components/workspace-navbar.js",
|
||||
"data.js",
|
||||
"logic/state-transitions.js",
|
||||
"logic/workspace.js",
|
||||
"logic/events.js",
|
||||
"components/cart.js",
|
||||
"components/navbar.js",
|
||||
"script.js",
|
||||
];
|
||||
|
||||
|
||||
@ -1,77 +1,184 @@
|
||||
(function () {
|
||||
const logic = (window.StoreLogic = window.StoreLogic || {});
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const store = StorefrontApp.store;
|
||||
const getters = StorefrontApp.getters;
|
||||
const { storeConfig, session } = StorefrontApp.data;
|
||||
|
||||
logic.bindEvents = function bindEvents(options) {
|
||||
const {
|
||||
state,
|
||||
closeStore,
|
||||
renderApp,
|
||||
toggleCart,
|
||||
closeCart,
|
||||
navigateToBreadcrumb,
|
||||
selectCategory,
|
||||
selectSubcategory,
|
||||
} = options;
|
||||
let noticeTimer = null;
|
||||
|
||||
const closeBtn = document.getElementById("store-close-btn");
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", closeStore);
|
||||
function showNotice(type, text) {
|
||||
store.setNotice({ type, text });
|
||||
|
||||
if (noticeTimer) {
|
||||
clearTimeout(noticeTimer);
|
||||
}
|
||||
|
||||
const cartToggleBtn = document.getElementById("store-cart-toggle-btn");
|
||||
if (cartToggleBtn) {
|
||||
cartToggleBtn.addEventListener("click", () => {
|
||||
toggleCart(state);
|
||||
renderApp();
|
||||
});
|
||||
noticeTimer = setTimeout(() => {
|
||||
store.setNotice({ type: "", text: "" });
|
||||
noticeTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
function applySearchQuery(value) {
|
||||
store.setSearchQuery(String(value || "").trim());
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
store.setSearchQuery("");
|
||||
}
|
||||
|
||||
function toggleCart() {
|
||||
store.setCartOpen((open) => !open);
|
||||
}
|
||||
|
||||
function closeCart() {
|
||||
store.setCartOpen(false);
|
||||
}
|
||||
|
||||
function closeStore() {
|
||||
const bridge = StorefrontApp.bridge;
|
||||
if (bridge && typeof bridge.requestClose === "function") {
|
||||
const sent = bridge.requestClose();
|
||||
if (sent) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const cartCloseBtn = document.getElementById("store-cart-close-btn");
|
||||
if (cartCloseBtn) {
|
||||
cartCloseBtn.addEventListener("click", () => {
|
||||
if (closeCart(state)) {
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
}
|
||||
showNotice("error", "Store bridge is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const cartBackdrop = document.getElementById("store-cart-backdrop");
|
||||
if (cartBackdrop) {
|
||||
cartBackdrop.addEventListener("click", () => {
|
||||
if (closeCart(state)) {
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
}
|
||||
function navigateToBreadcrumb(target) {
|
||||
return store.navigateToBreadcrumb(target);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll("[data-breadcrumb-target]")
|
||||
.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const target = button.getAttribute(
|
||||
"data-breadcrumb-target",
|
||||
);
|
||||
if (navigateToBreadcrumb(state, target)) {
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
});
|
||||
function selectCategory(category) {
|
||||
store.selectCategory(category);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-category]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const category = button.getAttribute("data-category");
|
||||
selectCategory(state, category);
|
||||
renderApp();
|
||||
});
|
||||
function selectSubcategory(subcategory, slotType) {
|
||||
store.selectSubcategory(subcategory, slotType);
|
||||
}
|
||||
|
||||
function addToCart(item) {
|
||||
store.setCartItems((currentItems) => {
|
||||
const existingIndex = currentItems.findIndex(
|
||||
(entry) => entry.code === item.code,
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
return [
|
||||
...currentItems,
|
||||
{
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const nextItems = [...currentItems];
|
||||
nextItems[existingIndex] = Object.assign(
|
||||
{},
|
||||
nextItems[existingIndex],
|
||||
{
|
||||
quantity: nextItems[existingIndex].quantity + 1,
|
||||
},
|
||||
);
|
||||
return nextItems;
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-subcategory]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const subcategory = button.getAttribute("data-subcategory");
|
||||
const slotType = button.getAttribute("data-subcategory-type");
|
||||
selectSubcategory(state, subcategory, slotType);
|
||||
renderApp();
|
||||
});
|
||||
store.setCartOpen(true);
|
||||
showNotice("success", `${item.name} added to the acquisition queue.`);
|
||||
}
|
||||
|
||||
function incrementCartItem(code) {
|
||||
store.setCartItems((currentItems) =>
|
||||
currentItems.map((item) =>
|
||||
item.code === code
|
||||
? Object.assign({}, item, { quantity: item.quantity + 1 })
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function decrementCartItem(code) {
|
||||
store.setCartItems((currentItems) =>
|
||||
currentItems
|
||||
.map((item) =>
|
||||
item.code === code
|
||||
? Object.assign({}, item, {
|
||||
quantity: Math.max(0, item.quantity - 1),
|
||||
})
|
||||
: item,
|
||||
)
|
||||
.filter((item) => item.quantity > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function removeCartItem(code) {
|
||||
store.setCartItems((currentItems) =>
|
||||
currentItems.filter((item) => item.code !== code),
|
||||
);
|
||||
}
|
||||
|
||||
function requestCheckout() {
|
||||
const cartItems = store.getCartItems();
|
||||
if (cartItems.length === 0) {
|
||||
showNotice("error", "Add at least one item before checkout.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = getters.summarizeCart(cartItems);
|
||||
if (summary.total > Number(storeConfig.budget || 0)) {
|
||||
showNotice(
|
||||
"error",
|
||||
"Checkout total exceeds the current procurement budget.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const bridge = StorefrontApp.bridge;
|
||||
if (!bridge || typeof bridge.requestCheckout !== "function") {
|
||||
showNotice("error", "Checkout bridge is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setIsCheckingOut(true);
|
||||
|
||||
const sent = bridge.requestCheckout({
|
||||
actorUid: session.actorUid,
|
||||
actorName: session.actorName,
|
||||
items: cartItems,
|
||||
subtotal: summary.subtotal,
|
||||
total: summary.total,
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
store.setIsCheckingOut(false);
|
||||
showNotice("error", "Checkout bridge is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
StorefrontApp.actions = {
|
||||
showNotice,
|
||||
applySearchQuery,
|
||||
clearSearch,
|
||||
toggleCart,
|
||||
closeCart,
|
||||
closeStore,
|
||||
navigateToBreadcrumb,
|
||||
selectCategory,
|
||||
selectSubcategory,
|
||||
addToCart,
|
||||
incrementCartItem,
|
||||
decrementCartItem,
|
||||
removeCartItem,
|
||||
requestCheckout,
|
||||
formatTitle: getters.formatTitle,
|
||||
formatCurrency: getters.formatCurrency,
|
||||
};
|
||||
})();
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
(function () {
|
||||
const logic = (window.StoreLogic = window.StoreLogic || {});
|
||||
|
||||
logic.toggleCart = function toggleCart(state) {
|
||||
state.cartOpen = !state.cartOpen;
|
||||
return true;
|
||||
};
|
||||
|
||||
logic.closeCart = function closeCart(state) {
|
||||
if (!state.cartOpen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.cartOpen = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
logic.navigateToBreadcrumb = function navigateToBreadcrumb(state, target) {
|
||||
switch (target) {
|
||||
case "categories":
|
||||
state.view = "categories";
|
||||
state.selectedCategory = "";
|
||||
state.selectedWeaponSlot = "";
|
||||
state.selectedVehicleSlot = "";
|
||||
return true;
|
||||
case "weapons":
|
||||
state.view = "weapons";
|
||||
state.selectedCategory = "weapons";
|
||||
state.selectedWeaponSlot = "";
|
||||
state.selectedVehicleSlot = "";
|
||||
return true;
|
||||
case "vehicles":
|
||||
state.view = "vehicles";
|
||||
state.selectedCategory = "vehicles";
|
||||
state.selectedVehicleSlot = "";
|
||||
state.selectedWeaponSlot = "";
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
logic.selectCategory = function selectCategory(state, category) {
|
||||
state.selectedCategory = category;
|
||||
state.selectedWeaponSlot = "";
|
||||
state.selectedVehicleSlot = "";
|
||||
|
||||
if (category === "weapons") {
|
||||
state.view = "weapons";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (category === "vehicles") {
|
||||
state.view = "vehicles";
|
||||
return true;
|
||||
}
|
||||
|
||||
state.view = "items";
|
||||
return true;
|
||||
};
|
||||
|
||||
logic.selectSubcategory = function selectSubcategory(
|
||||
state,
|
||||
subcategory,
|
||||
slotType,
|
||||
) {
|
||||
if (slotType === "vehicle") {
|
||||
state.selectedVehicleSlot = subcategory;
|
||||
state.selectedWeaponSlot = "";
|
||||
} else {
|
||||
state.selectedWeaponSlot = subcategory;
|
||||
state.selectedVehicleSlot = "";
|
||||
}
|
||||
|
||||
state.view = "items";
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
114
arma/client/addons/store/ui/_site/logic/store.js
Normal file
114
arma/client/addons/store/ui/_site/logic/store.js
Normal file
@ -0,0 +1,114 @@
|
||||
(function () {
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
SharedLogic.createStorefrontStore = function createStorefrontStore({
|
||||
createSignal,
|
||||
}) {
|
||||
class StorefrontStore {
|
||||
constructor() {
|
||||
[this.getView, this.setView] = createSignal("categories");
|
||||
[this.getSelectedCategory, this.setSelectedCategory] =
|
||||
createSignal("");
|
||||
[this.getSelectedWeaponSlot, this.setSelectedWeaponSlot] =
|
||||
createSignal("");
|
||||
[this.getSelectedVehicleSlot, this.setSelectedVehicleSlot] =
|
||||
createSignal("");
|
||||
[this.getCartOpen, this.setCartOpen] = createSignal(false);
|
||||
[this.getSearchQuery, this.setSearchQuery] = createSignal("");
|
||||
[this.getCartItems, this.setCartItems] = createSignal([]);
|
||||
[this.getNotice, this.setNotice] = createSignal({
|
||||
type: "",
|
||||
text: "",
|
||||
});
|
||||
[this.getIsCheckingOut, this.setIsCheckingOut] =
|
||||
createSignal(false);
|
||||
}
|
||||
|
||||
resetToCategories() {
|
||||
this.setView("categories");
|
||||
this.setSelectedCategory("");
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setSelectedVehicleSlot("");
|
||||
}
|
||||
|
||||
openWeaponsRoot() {
|
||||
this.setView("weapons");
|
||||
this.setSelectedCategory("weapons");
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setSelectedVehicleSlot("");
|
||||
}
|
||||
|
||||
openVehiclesRoot() {
|
||||
this.setView("vehicles");
|
||||
this.setSelectedCategory("vehicles");
|
||||
this.setSelectedVehicleSlot("");
|
||||
this.setSelectedWeaponSlot("");
|
||||
}
|
||||
|
||||
selectCategory(category) {
|
||||
this.setSelectedCategory(category);
|
||||
this.setSelectedWeaponSlot("");
|
||||
this.setSelectedVehicleSlot("");
|
||||
|
||||
if (category === "weapons") {
|
||||
this.openWeaponsRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
if (category === "vehicles") {
|
||||
this.openVehiclesRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setView("items");
|
||||
}
|
||||
|
||||
selectSubcategory(subcategory, slotType) {
|
||||
if (slotType === "vehicle") {
|
||||
this.setSelectedVehicleSlot(subcategory);
|
||||
this.setSelectedWeaponSlot("");
|
||||
} else {
|
||||
this.setSelectedWeaponSlot(subcategory);
|
||||
this.setSelectedVehicleSlot("");
|
||||
}
|
||||
|
||||
this.setView("items");
|
||||
}
|
||||
|
||||
navigateToBreadcrumb(target) {
|
||||
switch (target) {
|
||||
case "categories":
|
||||
this.resetToCategories();
|
||||
return true;
|
||||
case "weapons":
|
||||
this.openWeaponsRoot();
|
||||
return true;
|
||||
case "vehicles":
|
||||
this.openVehiclesRoot();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hydrateFromPayload(payload) {
|
||||
const cartItems = Array.isArray(payload?.cartItems)
|
||||
? payload.cartItems
|
||||
: [];
|
||||
|
||||
this.setCartItems(
|
||||
cartItems.map((item) => ({
|
||||
code: String(item.code || ""),
|
||||
name: String(item.name || ""),
|
||||
price: String(item.price || "$0"),
|
||||
quantity: Math.max(1, Number(item.quantity || 1)),
|
||||
})),
|
||||
);
|
||||
this.setCartOpen(false);
|
||||
this.setIsCheckingOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new StorefrontStore();
|
||||
};
|
||||
})();
|
||||
@ -1,113 +0,0 @@
|
||||
(function () {
|
||||
const logic = (window.StoreLogic = window.StoreLogic || {});
|
||||
|
||||
logic.formatTitle = function formatTitle(value) {
|
||||
return String(value || "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
logic.getWorkspaceHeader = function getWorkspaceHeader(state, formatTitle) {
|
||||
if (state.view === "weapons") {
|
||||
return {
|
||||
eyebrow: "Weapons Division",
|
||||
title: "Weapon Categories",
|
||||
copy: "Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are scaffolded for tomorrow's item wiring.",
|
||||
badge: "3 Slots",
|
||||
ribbon: "Category Routing Active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.view === "vehicles") {
|
||||
return {
|
||||
eyebrow: "Vehicle Motorpool",
|
||||
title: "Vehicle Categories",
|
||||
copy: "Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options are scaffolded for item wiring.",
|
||||
badge: "6 Classes",
|
||||
ribbon: "Category Routing Active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.view === "items") {
|
||||
const label =
|
||||
state.selectedWeaponSlot ||
|
||||
state.selectedVehicleSlot ||
|
||||
state.selectedCategory ||
|
||||
"catalog";
|
||||
|
||||
return {
|
||||
eyebrow: "Catalog Preview",
|
||||
title: formatTitle(label),
|
||||
copy: "Mock product cards with placeholder imagery sized for future filtering, search, and cart logic.",
|
||||
badge: "Preview Items",
|
||||
ribbon: "Selection Locked",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: "Supply Categories",
|
||||
title: "Procurement Dashboard",
|
||||
copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory.",
|
||||
badge: "8 Categories",
|
||||
ribbon: "Mock Layout",
|
||||
};
|
||||
};
|
||||
|
||||
logic.renderWorkspaceBody = function renderWorkspaceBody(
|
||||
state,
|
||||
data,
|
||||
components,
|
||||
) {
|
||||
if (state.view === "weapons") {
|
||||
return `
|
||||
<div class="workspace-grid is-wide">
|
||||
${data.weaponCards
|
||||
.map((category) =>
|
||||
components.createSubCategoryCard(
|
||||
category,
|
||||
"weapon",
|
||||
),
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.view === "vehicles") {
|
||||
return `
|
||||
<div class="workspace-grid is-wide">
|
||||
${data.vehicleCards
|
||||
.map((category) =>
|
||||
components.createSubCategoryCard(
|
||||
category,
|
||||
"vehicle",
|
||||
),
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (state.view === "items") {
|
||||
const key =
|
||||
state.selectedWeaponSlot ||
|
||||
state.selectedVehicleSlot ||
|
||||
state.selectedCategory;
|
||||
const items = data.previewItems[key] || [];
|
||||
|
||||
return `
|
||||
<div class="workspace-grid is-products">
|
||||
${items.map(components.createProductCard).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="workspace-grid">
|
||||
${data.categoryCards.map(components.createCategoryCard).join("")}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
})();
|
||||
210
arma/client/addons/store/ui/_site/pages/StoreView.js
Normal file
210
arma/client/addons/store/ui/_site/pages/StoreView.js
Normal file
@ -0,0 +1,210 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
|
||||
function getSelectionKey(state) {
|
||||
return (
|
||||
state.selectedWeaponSlot ||
|
||||
state.selectedVehicleSlot ||
|
||||
state.selectedCategory
|
||||
);
|
||||
}
|
||||
|
||||
function matchesQuery(query, values) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedQuery = String(query).trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return values.some((value) =>
|
||||
String(value || "")
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
function parsePrice(value) {
|
||||
const parsed = Number(String(value || "0").replace(/[^0-9.-]+/g, ""));
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return `$${Number(value || 0).toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatTitle(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(part) =>
|
||||
part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getStoreState(store) {
|
||||
return {
|
||||
view: store.getView(),
|
||||
selectedCategory: store.getSelectedCategory(),
|
||||
selectedWeaponSlot: store.getSelectedWeaponSlot(),
|
||||
selectedVehicleSlot: store.getSelectedVehicleSlot(),
|
||||
cartOpen: store.getCartOpen(),
|
||||
searchQuery: store.getSearchQuery(),
|
||||
cartItems: store.getCartItems(),
|
||||
isCheckingOut: store.getIsCheckingOut(),
|
||||
};
|
||||
}
|
||||
|
||||
function getStoreHeader(state) {
|
||||
if (state.view === "weapons") {
|
||||
return {
|
||||
eyebrow: "Weapons Division",
|
||||
title: "Weapon Categories",
|
||||
copy: "Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are staged with the same state and bridge flow as the org portal.",
|
||||
badge: "3 Slots",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.view === "vehicles") {
|
||||
return {
|
||||
eyebrow: "Vehicle Motorpool",
|
||||
title: "Vehicle Categories",
|
||||
copy: "Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options stay inside the same local store and bridge lifecycle.",
|
||||
badge: "6 Classes",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.view === "items") {
|
||||
const label = getSelectionKey(state) || "catalog";
|
||||
const queryLabel = state.searchQuery
|
||||
? ` Filtered by "${state.searchQuery}".`
|
||||
: "";
|
||||
|
||||
return {
|
||||
eyebrow: "Catalog Preview",
|
||||
title: formatTitle(label),
|
||||
copy: `Mock product cards with placeholder imagery sized for future filtering, search, and cart logic.${queryLabel}`,
|
||||
badge: "Preview Items",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: "Supply Categories",
|
||||
title: "Procurement Dashboard",
|
||||
copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.",
|
||||
badge: "8 Categories",
|
||||
};
|
||||
}
|
||||
|
||||
function getStoreBreadcrumbs(state) {
|
||||
const items = [{ id: "categories", label: "Supply Exchange" }];
|
||||
|
||||
if (state.view === "weapons") {
|
||||
items.push({ id: "weapons", label: "Weapons" });
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.view === "vehicles") {
|
||||
items.push({ id: "vehicles", label: "Vehicles" });
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.view === "items") {
|
||||
if (state.selectedWeaponSlot) {
|
||||
items.push({ id: "weapons", label: "Weapons" });
|
||||
items.push({
|
||||
id: "weapon-slot",
|
||||
label: formatTitle(state.selectedWeaponSlot),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.selectedVehicleSlot) {
|
||||
items.push({ id: "vehicles", label: "Vehicles" });
|
||||
items.push({
|
||||
id: "vehicle-slot",
|
||||
label: formatTitle(state.selectedVehicleSlot),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
if (state.selectedCategory) {
|
||||
items.push({
|
||||
id: "category",
|
||||
label: formatTitle(state.selectedCategory),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function getVisibleCategoryCards(state, catalog) {
|
||||
return catalog.categoryCards.filter((category) =>
|
||||
matchesQuery(state.searchQuery, [category.id, category.label]),
|
||||
);
|
||||
}
|
||||
|
||||
function getVisibleSubcategoryCards(state, catalog) {
|
||||
const source =
|
||||
state.view === "vehicles"
|
||||
? catalog.vehicleCards
|
||||
: catalog.weaponCards;
|
||||
|
||||
return source.filter((category) =>
|
||||
matchesQuery(state.searchQuery, [category.id, category.label]),
|
||||
);
|
||||
}
|
||||
|
||||
function getVisibleItems(state, catalog) {
|
||||
const key = getSelectionKey(state);
|
||||
const items = catalog.previewItems[key] || [];
|
||||
|
||||
return items.filter((item) =>
|
||||
matchesQuery(state.searchQuery, [
|
||||
item.code,
|
||||
item.name,
|
||||
item.description,
|
||||
item.price,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeCart(cartItems) {
|
||||
const itemCount = cartItems.reduce(
|
||||
(sum, item) => sum + Number(item.quantity || 0),
|
||||
0,
|
||||
);
|
||||
const subtotal = cartItems.reduce(
|
||||
(sum, item) =>
|
||||
sum + parsePrice(item.price) * Number(item.quantity || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
lineCount: cartItems.length,
|
||||
itemCount,
|
||||
subtotal,
|
||||
total: subtotal,
|
||||
};
|
||||
}
|
||||
|
||||
StorefrontApp.getters = {
|
||||
formatTitle,
|
||||
formatCurrency,
|
||||
parsePrice,
|
||||
getSelectionKey,
|
||||
getStoreState,
|
||||
getStoreHeader,
|
||||
getStoreBreadcrumbs,
|
||||
getVisibleCategoryCards,
|
||||
getVisibleSubcategoryCards,
|
||||
getVisibleItems,
|
||||
summarizeCart,
|
||||
};
|
||||
})();
|
||||
155
arma/client/addons/store/ui/_site/runtime.js
Normal file
155
arma/client/addons/store/ui/_site/runtime.js
Normal file
@ -0,0 +1,155 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SVG_TAGS = new Set([
|
||||
"svg",
|
||||
"path",
|
||||
"circle",
|
||||
"rect",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"g",
|
||||
"defs",
|
||||
"use",
|
||||
"text",
|
||||
"tspan",
|
||||
"clipPath",
|
||||
"mask",
|
||||
]);
|
||||
|
||||
function appendChild(el, child) {
|
||||
if (child === null || child === undefined || child === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach((entry) => appendChild(el, entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof child === "string" || typeof child === "number") {
|
||||
el.appendChild(document.createTextNode(String(child)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (child instanceof Node) {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function h(tag, props = {}, ...children) {
|
||||
const isSvg = SVG_TAGS.has(tag);
|
||||
const el = isSvg
|
||||
? document.createElementNS(SVG_NS, tag)
|
||||
: document.createElement(tag);
|
||||
|
||||
if (props) {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "className") {
|
||||
if (isSvg) {
|
||||
el.setAttribute("class", value);
|
||||
} else {
|
||||
el.className = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "style" && typeof value === "object") {
|
||||
Object.assign(el.style, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "value" && "value" in el) {
|
||||
el.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "checked" && "checked" in el) {
|
||||
el.checked = Boolean(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
if (value) {
|
||||
el.setAttribute(key, "");
|
||||
} else {
|
||||
el.removeAttribute(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
el.removeAttribute(key);
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
children.forEach((child) => appendChild(el, child));
|
||||
return el;
|
||||
}
|
||||
|
||||
let rootContainer = null;
|
||||
let rootComponent = null;
|
||||
const injectedStyles = new Set();
|
||||
|
||||
function render(component, container) {
|
||||
rootContainer = container;
|
||||
rootComponent = component;
|
||||
rerender();
|
||||
}
|
||||
|
||||
function rerender() {
|
||||
if (!rootContainer || !rootComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootContainer.innerHTML = "";
|
||||
rootContainer.appendChild(rootComponent());
|
||||
}
|
||||
|
||||
function ensureScopedStyle(id, cssText) {
|
||||
if (!id || !cssText || injectedStyles.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-ui-style", id);
|
||||
style.textContent = cssText;
|
||||
document.head.appendChild(style);
|
||||
injectedStyles.add(id);
|
||||
}
|
||||
|
||||
function createSignal(initialValue) {
|
||||
let value = initialValue;
|
||||
|
||||
const getValue = () => value;
|
||||
const setValue = (nextValue) => {
|
||||
value =
|
||||
typeof nextValue === "function" ? nextValue(value) : nextValue;
|
||||
rerender();
|
||||
};
|
||||
|
||||
return [getValue, setValue];
|
||||
}
|
||||
|
||||
const runtime = {
|
||||
h,
|
||||
render,
|
||||
rerender,
|
||||
createSignal,
|
||||
ensureScopedStyle,
|
||||
};
|
||||
|
||||
StorefrontApp.runtime = runtime;
|
||||
window.AppRuntime = runtime;
|
||||
})();
|
||||
@ -1,114 +1,25 @@
|
||||
(function () {
|
||||
const state = {
|
||||
view: "categories",
|
||||
selectedCategory: "",
|
||||
selectedWeaponSlot: "",
|
||||
selectedVehicleSlot: "",
|
||||
cartOpen: false,
|
||||
};
|
||||
|
||||
function getStoreData(name) {
|
||||
const data = window.StoreData && window.StoreData[name];
|
||||
if (typeof data === "undefined") {
|
||||
throw new Error(`[Store UI] Missing store data: ${name}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function getComponentFn(name) {
|
||||
const fn = window.StoreComponents && window.StoreComponents[name];
|
||||
if (typeof fn !== "function") {
|
||||
throw new Error(`[Store UI] Missing component function: ${name}`);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
function getLogicFn(name) {
|
||||
const fn = window.StoreLogic && window.StoreLogic[name];
|
||||
if (typeof fn !== "function") {
|
||||
throw new Error(`[Store UI] Missing logic function: ${name}`);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
const data = {
|
||||
categoryCards: getStoreData("categoryCards"),
|
||||
vehicleCards: getStoreData("vehicleCards"),
|
||||
weaponCards: getStoreData("weaponCards"),
|
||||
previewItems: getStoreData("previewItems"),
|
||||
};
|
||||
|
||||
const components = {
|
||||
renderAppShell: getComponentFn("renderAppShell"),
|
||||
createCategoryCard: getComponentFn("createCategoryCard"),
|
||||
createSubCategoryCard: getComponentFn("createSubCategoryCard"),
|
||||
createProductCard: getComponentFn("createProductCard"),
|
||||
renderCartPanel: getComponentFn("renderCartPanel"),
|
||||
renderWorkspaceNavbar: getComponentFn("renderWorkspaceNavbar"),
|
||||
};
|
||||
|
||||
const formatTitle = getLogicFn("formatTitle");
|
||||
const getWorkspaceHeader = getLogicFn("getWorkspaceHeader");
|
||||
const renderWorkspaceBody = getLogicFn("renderWorkspaceBody");
|
||||
const bindEvents = getLogicFn("bindEvents");
|
||||
const toggleCart = getLogicFn("toggleCart");
|
||||
const closeCart = getLogicFn("closeCart");
|
||||
const navigateToBreadcrumb = getLogicFn("navigateToBreadcrumb");
|
||||
const selectCategory = getLogicFn("selectCategory");
|
||||
const selectSubcategory = getLogicFn("selectSubcategory");
|
||||
|
||||
function sendEvent(event, payload) {
|
||||
if (
|
||||
typeof A3API !== "undefined" &&
|
||||
typeof A3API.SendAlert === "function"
|
||||
) {
|
||||
A3API.SendAlert(
|
||||
JSON.stringify({
|
||||
event,
|
||||
data: payload,
|
||||
}),
|
||||
);
|
||||
function mountStorefront() {
|
||||
const root = document.getElementById("app");
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Store UI]", event, payload);
|
||||
}
|
||||
|
||||
function closeStore() {
|
||||
sendEvent("store::close", {});
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
const header = getWorkspaceHeader(state, formatTitle);
|
||||
const workspaceNavbar = components.renderWorkspaceNavbar(
|
||||
state,
|
||||
formatTitle,
|
||||
window.StorefrontApp.runtime.render(
|
||||
window.StorefrontApp.components.App,
|
||||
root,
|
||||
);
|
||||
const workspaceBody = renderWorkspaceBody(state, data, components);
|
||||
const cartPanel = components.renderCartPanel(state);
|
||||
|
||||
document.getElementById("app").innerHTML = components.renderAppShell({
|
||||
header,
|
||||
workspaceNavbar,
|
||||
workspaceBody,
|
||||
cartPanel,
|
||||
});
|
||||
|
||||
bindEvents({
|
||||
state,
|
||||
closeStore,
|
||||
renderApp,
|
||||
toggleCart,
|
||||
closeCart,
|
||||
navigateToBreadcrumb,
|
||||
selectCategory,
|
||||
selectSubcategory,
|
||||
});
|
||||
if (window.StorefrontApp.bridge) {
|
||||
window.StorefrontApp.bridge.notifyReady();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", renderApp);
|
||||
document.addEventListener("DOMContentLoaded", mountStorefront, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
renderApp();
|
||||
mountStorefront();
|
||||
}
|
||||
})();
|
||||
|
||||
@ -1,27 +1,20 @@
|
||||
:root {
|
||||
--titlebar-bg: linear-gradient(180deg, #173a63 0%, #0e2c4f 100%);
|
||||
--titlebar-border: rgba(161, 190, 224, 0.18);
|
||||
--shell-bg: #e4e3df;
|
||||
--surface: #f5f3ef;
|
||||
--surface-alt: #ece8e2;
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #dfe5eb;
|
||||
--border: rgba(74, 91, 110, 0.2);
|
||||
--border-strong: rgba(20, 46, 79, 0.2);
|
||||
--text-main: #1f2d3d;
|
||||
--text-muted: #6a7787;
|
||||
--text-subtle: #8792a0;
|
||||
--accent: #12365d;
|
||||
--accent-soft: #dbe7f3;
|
||||
--accent-line: rgba(18, 54, 93, 0.12);
|
||||
--success: #2f7d5b;
|
||||
--danger: #8a3d3d;
|
||||
--shadow-lg: 0 18px 48px rgb(14 32 54 / 0.12);
|
||||
--shadow-md: 0 10px 26px rgb(17 35 59 / 0.08);
|
||||
--radius-xl: 22px;
|
||||
--radius-lg: 16px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 9px;
|
||||
--store-titlebar-bg: linear-gradient(180deg, #173a63 0%, #0e2c4f 100%);
|
||||
--store-titlebar-border: rgba(161, 190, 224, 0.18);
|
||||
--store-shell-bg: #e4e3df;
|
||||
--store-surface: #f5f3ef;
|
||||
--store-surface-alt: #ece8e2;
|
||||
--store-surface-strong: #ffffff;
|
||||
--store-border: rgba(74, 91, 110, 0.2);
|
||||
--store-border-strong: rgba(20, 46, 79, 0.2);
|
||||
--store-text-main: #1f2d3d;
|
||||
--store-text-muted: #6a7787;
|
||||
--store-text-subtle: #8792a0;
|
||||
--store-accent: #12365d;
|
||||
--store-accent-soft: #dbe7f3;
|
||||
--store-accent-line: rgba(18, 54, 93, 0.12);
|
||||
--store-success: #2f7d5b;
|
||||
--store-danger: #8a3d3d;
|
||||
}
|
||||
|
||||
* {
|
||||
@ -38,8 +31,8 @@ body {
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
|
||||
color: var(--text-main);
|
||||
background: var(--shell-bg);
|
||||
color: var(--store-text-main);
|
||||
background: var(--store-shell-bg);
|
||||
}
|
||||
|
||||
button,
|
||||
@ -52,751 +45,47 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(18 54 93 / 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.store-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--shell-bg);
|
||||
}
|
||||
|
||||
.window-titlebar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem 0.95rem 1.2rem;
|
||||
background: var(--titlebar-bg);
|
||||
color: #f4f8fd;
|
||||
border-bottom: 1px solid var(--titlebar-border);
|
||||
}
|
||||
|
||||
.window-titlebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.window-titlebar-kicker {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(214 227 241 / 0.72);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.window-titlebar-title {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.window-titlebar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.cart-toggle-icon {
|
||||
position: relative;
|
||||
width: 0.95rem;
|
||||
height: 0.8rem;
|
||||
border: 1.5px solid currentcolor;
|
||||
border-radius: 0.16rem 0.16rem 0.24rem 0.24rem;
|
||||
}
|
||||
|
||||
.cart-toggle-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -0.34rem;
|
||||
left: 0.2rem;
|
||||
width: 0.5rem;
|
||||
height: 0.3rem;
|
||||
border: 1.5px solid currentcolor;
|
||||
border-bottom: 0;
|
||||
border-radius: 0.35rem 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.window-control-btn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgb(197 220 243 / 0.16);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: rgb(237 244 251 / 0.88);
|
||||
}
|
||||
|
||||
.window-control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.window-control-btn.is-close {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
.window-control-btn.is-close:hover {
|
||||
background: rgb(185 67 67 / 0.9);
|
||||
border-color: rgb(255 222 222 / 0.45);
|
||||
}
|
||||
|
||||
.store-app {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: min(100%, 1613px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 308px minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.store-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 1.25rem 1.15rem;
|
||||
border-top: 1px solid rgb(18 54 93 / 0.1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.footer-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.footer-title {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.store-sidebar,
|
||||
.store-main {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.store-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-card,
|
||||
.workspace-card,
|
||||
.cart-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--surface) 0%,
|
||||
var(--surface-alt) 100%
|
||||
);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.module-card,
|
||||
.cart-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 1280px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 0.95rem;
|
||||
border-bottom: 1px solid var(--accent-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgb(236 241 246 / 0.52) 0%,
|
||||
rgb(245 243 239 / 0.2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.workspace-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb-link,
|
||||
.breadcrumb-current,
|
||||
.breadcrumb-separator {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: rgb(124 138 155 / 0.72);
|
||||
}
|
||||
|
||||
.workspace-cart-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: rgb(255 255 255 / 0.68);
|
||||
color: var(--accent);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75);
|
||||
}
|
||||
|
||||
.workspace-cart-btn:hover {
|
||||
background: rgb(219 231 243 / 0.88);
|
||||
}
|
||||
|
||||
.module-header,
|
||||
.workspace-header,
|
||||
.cart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.module-header {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.workspace-header,
|
||||
.cart-header {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.48rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.search-module {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 2.9rem;
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: rgb(255 255 255 / 0.75);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.quick-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-tag {
|
||||
padding: 0.55rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgb(255 255 255 / 0.52);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-stack {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
padding: 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(255 255 255 / 0.48);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: block;
|
||||
margin-bottom: 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-main);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workspace-intro {
|
||||
padding: 0 1rem 1rem;
|
||||
border-bottom: 1px solid var(--accent-line);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.workspace-grid.is-wide {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workspace-grid.is-products {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.nav-btn,
|
||||
.cart-btn,
|
||||
.action-btn {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(255 255 255 / 0.68);
|
||||
color: var(--accent);
|
||||
.store-btn {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid var(--store-border-strong);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-btn,
|
||||
.cart-btn {
|
||||
height: 2.8rem;
|
||||
padding: 0 1rem;
|
||||
.store-btn.store-btn-primary {
|
||||
background: rgb(255 255 255 / 0.68);
|
||||
color: var(--store-accent);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-height: 2.8rem;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.nav-btn:hover,
|
||||
.cart-btn:hover,
|
||||
.action-btn:hover {
|
||||
.store-btn.store-btn-primary:hover {
|
||||
background: rgb(219 231 243 / 0.88);
|
||||
}
|
||||
|
||||
.card-button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgb(255 255 255 / 0.7) 0%,
|
||||
rgb(226 233 239 / 0.88) 100%
|
||||
),
|
||||
var(--surface-strong);
|
||||
color: var(--accent);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75);
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgb(18 54 93 / 0.28);
|
||||
box-shadow:
|
||||
0 14px 28px rgb(19 37 60 / 0.1),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.85);
|
||||
}
|
||||
|
||||
.category-card,
|
||||
.subcategory-card {
|
||||
min-height: 12.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
text-align: center;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
min-height: 20rem;
|
||||
padding: 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
height: 9.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed rgb(18 54 93 / 0.24);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(235 240 245) 0%,
|
||||
rgb(221 228 235) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-code {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.product-copy {
|
||||
margin: 0;
|
||||
min-height: 2.8rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.product-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.cart-card {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cart-panel-close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cart-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cart-overlay.is-open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.cart-overlay-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.store-cart-panel {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
width: min(22rem, calc(100% - 1rem));
|
||||
transform: translateX(calc(100% + 1rem));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.cart-overlay.is-open .store-cart-panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.store-cart-panel .cart-card {
|
||||
height: 100%;
|
||||
border-radius: 1.5rem;
|
||||
box-shadow:
|
||||
0 18px 40px rgb(11 27 46 / 0.16),
|
||||
0 4px 12px rgb(11 27 46 / 0.08);
|
||||
}
|
||||
|
||||
.cart-status {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(255 255 255 / 0.6);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cart-kpi {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cart-kpi-card {
|
||||
padding: 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(255 255 255 / 0.5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-lines {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cart-line {
|
||||
padding: 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(255 255 255 / 0.56);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cart-line-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cart-line-meta {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--accent-line);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-row.total {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.muted-btn {
|
||||
.store-btn.store-btn-secondary {
|
||||
background: rgb(255 255 255 / 0.42);
|
||||
color: var(--text-muted);
|
||||
color: var(--store-text-muted);
|
||||
}
|
||||
|
||||
.inventory-ribbon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.56);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.store-app {
|
||||
grid-template-columns: 284px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace-grid,
|
||||
.workspace-grid.is-products {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.store-app {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.store-sidebar,
|
||||
.store-main {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.workspace-grid,
|
||||
.workspace-grid.is-wide,
|
||||
.workspace-grid.is-products {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.store-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.store-main {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-navbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.store-cart-panel {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(24rem, 100%);
|
||||
}
|
||||
.store-btn.store-btn-secondary:hover {
|
||||
background: rgb(255 255 255 / 0.6);
|
||||
color: var(--store-text-main);
|
||||
}
|
||||
|
||||
9
arma/client/addons/store/ui/_site/useStore.js
Normal file
9
arma/client/addons/store/ui/_site/useStore.js
Normal file
@ -0,0 +1,9 @@
|
||||
(function () {
|
||||
const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {});
|
||||
const { createSignal } = StorefrontApp.runtime;
|
||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||
|
||||
StorefrontApp.store = SharedLogic.createStorefrontStore({
|
||||
createSignal,
|
||||
});
|
||||
})();
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
"path": ".",
|
||||
},
|
||||
],
|
||||
"settings": {
|
||||
"editor.insertSpaces": true,
|
||||
@ -16,7 +16,7 @@
|
||||
"*.hpp": "arma-config",
|
||||
"*.inc": "arma-config",
|
||||
"*.cfg": "arma-config",
|
||||
"*.rvmat": "arma-config"
|
||||
}
|
||||
}
|
||||
"*.rvmat": "arma-config",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -23,14 +23,16 @@
|
||||
</p>
|
||||
|
||||
# Initial Project Setup!
|
||||
|
||||
Delete this section after the project has been initially set up:
|
||||
|
||||
1. Find and replace all instances of `forge-client` with the mod's name.
|
||||
2. Find and replace all instances of `MOD_REPO` with the mod's name *and no spaces*.
|
||||
- This should be the name of the repository on GitHub.
|
||||
2. Find and replace all instances of `MOD_REPO` with the mod's name _and no spaces_.
|
||||
- This should be the name of the repository on GitHub.
|
||||
3. Find and replace all instances of `forge_client` with the mod's prefix.
|
||||
- This should be all lowercase.
|
||||
- This should be all lowercase.
|
||||
4. Find and replace all instances of `MOD_ACRONYM` with the mod's acronym.
|
||||
- This should be all uppercase.
|
||||
- This should be all uppercase.
|
||||
5. After the initial Steam upload, find and replace all instances of `MOD_ID` with the mod's Steam Workshop id.
|
||||
|
||||
For third parties, make sure to also replace `IDSolutions` with your Github username / organization name, and to replace `DartRuffian` with your username.
|
||||
@ -40,10 +42,13 @@ For third parties, make sure to also replace `IDSolutions` with your Github user
|
||||
The project is entirely **open-source** and any contributions are welcome.
|
||||
|
||||
## Core Features
|
||||
|
||||
- Feature
|
||||
|
||||
## Contributing
|
||||
|
||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
forge-client is licensed under [APL-ND](./LICENSE.md).
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_client_addonName
|
||||
===================
|
||||
# forge_client_addonName
|
||||
|
||||
Description for this addon
|
||||
|
||||
5
arma/server/.github/CONTRIBUTING.md
vendored
5
arma/server/.github/CONTRIBUTING.md
vendored
@ -1,12 +1,17 @@
|
||||
# Contributing Setup & Guidelines
|
||||
|
||||
## Setting up the Development Environment
|
||||
|
||||
### 1. Clone the repository from GitHub
|
||||
|
||||
### 2. Install HEMTT
|
||||
|
||||
The latest version of HEMTT can be installed by running:
|
||||
|
||||
```cmd
|
||||
winget install hemtt
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
|
||||
|
||||
@ -1,25 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us improve
|
||||
title: ''
|
||||
title: ""
|
||||
labels: kind/bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Attachments
|
||||
|
||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a feature to be added
|
||||
title: ''
|
||||
title: ""
|
||||
labels: kind/feature-request
|
||||
---
|
||||
|
||||
## Describe the feature that you would like
|
||||
|
||||
A clear and concise description of the feature you'd want.
|
||||
|
||||
## Possible alternatives
|
||||
|
||||
Possible alternatives to your suggestion.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the feature here.
|
||||
|
||||
4
arma/server/.github/PULL_REQUEST_TEMPLATE.md
vendored
4
arma/server/.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,12 +1,16 @@
|
||||
**When merged this pull request will:**
|
||||
|
||||
- Describe what this pull request will do
|
||||
- Each change in a separate line
|
||||
|
||||
### Important
|
||||
|
||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
||||
- [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied.
|
||||
- [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`.
|
||||
|
||||
<!-- Known issues that need to be addressed -->
|
||||
|
||||
### Known Issues
|
||||
|
||||
- [ ] Issue
|
||||
|
||||
24
arma/server/.github/workflows/check.yml
vendored
24
arma/server/.github/workflows/check.yml
vendored
@ -12,17 +12,17 @@ jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout the source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Config
|
||||
run: python tools/config_style_checker.py
|
||||
- name: Check for BOM
|
||||
uses: arma-actions/bom-check@master
|
||||
with:
|
||||
path: "addons"
|
||||
- name: Validate Config
|
||||
run: python tools/config_style_checker.py
|
||||
- name: Check for BOM
|
||||
uses: arma-actions/bom-check@master
|
||||
with:
|
||||
path: "addons"
|
||||
|
||||
- name: Setup HEMTT
|
||||
uses: arma-actions/hemtt@v1
|
||||
- name: Run HEMTT check
|
||||
run: hemtt check --pedantic
|
||||
- name: Setup HEMTT
|
||||
uses: arma-actions/hemtt@v1
|
||||
- name: Run HEMTT check
|
||||
run: hemtt check --pedantic
|
||||
|
||||
@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY
|
||||
|
||||
With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions:
|
||||
|
||||
* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
|
||||
* **Noncommercial** - You may not use this material for any commercial purposes.
|
||||
* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
|
||||
* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
|
||||
- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material).
|
||||
- **Noncommercial** - You may not use this material for any commercial purposes.
|
||||
- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma.
|
||||
- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license.
|
||||
|
||||
---
|
||||
|
||||
@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
||||
2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
2. upon express reinstatement by the Licensor.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your
|
||||
### Bohemia Interactive Notices
|
||||
|
||||
1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor".
|
||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||
2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s.
|
||||
|
||||
@ -18,10 +18,13 @@
|
||||
The project is entirely **open-source** and any contributions are welcome.
|
||||
|
||||
## Core Features
|
||||
|
||||
- Feature
|
||||
|
||||
## Contributing
|
||||
|
||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Forge Server is licensed under [APL-SA](./LICENSE.md).
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_actor
|
||||
===================
|
||||
# forge_server_actor
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_bank
|
||||
===================
|
||||
# forge_server_bank
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_common
|
||||
===================
|
||||
# forge_server_common
|
||||
|
||||
Common functionality shared between addons.
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_economy
|
||||
===================
|
||||
# forge_server_economy
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_extension
|
||||
===================
|
||||
# forge_server_extension
|
||||
|
||||
Extension functionality shared between addons.
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_garage
|
||||
===================
|
||||
# forge_server_garage
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_locker
|
||||
===================
|
||||
# forge_server_locker
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_main
|
||||
===================
|
||||
# forge_server_main
|
||||
|
||||
Main Addon for forge-server
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
forge_server_org
|
||||
===================
|
||||
# forge_server_org
|
||||
|
||||
Description for this addon
|
||||
|
||||
@ -5,6 +5,7 @@ A high-performance arma-rs extension for Arma 3, featuring a **low-level Redis d
|
||||
## 🎯 Overview
|
||||
|
||||
The Forge Server Redis module is designed as a **foundational data access layer** that:
|
||||
|
||||
- **Returns raw Redis responses** for maximum performance and flexibility
|
||||
- **Serves as the foundation** for higher-level game modules (actor, garage, locker, bank, etc.)
|
||||
- **Provides connection pooling** and error handling for Redis operations
|
||||
@ -17,7 +18,7 @@ The Forge Server Redis module is designed as a **foundational data access layer*
|
||||
SQF Scripts
|
||||
↓ (JSON responses)
|
||||
Game Modules (actor, garage, locker, bank)
|
||||
↓ (raw Redis responses)
|
||||
↓ (raw Redis responses)
|
||||
Redis Client Module
|
||||
↓ (Redis protocol)
|
||||
Redis Server
|
||||
@ -43,6 +44,7 @@ forge_server_x64.dll Extension
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
- **lib.rs**: Manages global Redis pool and single Tokio runtime
|
||||
- **macros.rs**: Provides `redis_operation!` macro to eliminate boilerplate
|
||||
- **Operation modules**: Focus purely on Redis logic using the macro
|
||||
@ -51,12 +53,14 @@ forge_server_x64.dll Extension
|
||||
## 🚀 Features
|
||||
|
||||
### Raw Redis Operations
|
||||
|
||||
- **String Operations**: SET, GET, INCR, DECR, DEL, KEYS
|
||||
- **Hash Operations**: HSET, HGET, HMSET, HGETALL, HDEL, HKEYS, HVALS, HLEN
|
||||
- **List Operations**: LSET, LGET, LLEN, LRANGE, LPUSH, RPUSH, LPOP, RPOP, LTRIM, LREM
|
||||
- **Set Operations**: SADD, SMEMBERS, SCARD, SREM, SISMEMBER, SPOP, SRANDMEMBER
|
||||
|
||||
### Performance Features
|
||||
|
||||
- **Connection Pooling**: bb8-redis pool with configurable size and timeouts
|
||||
- **Single Runtime**: One shared Tokio runtime for all async operations
|
||||
- **Macro-Based**: `redis_operation!` macro eliminates boilerplate while maintaining performance
|
||||
@ -87,11 +91,13 @@ port = 6379
|
||||
### Fallback Behavior
|
||||
|
||||
The extension uses a robust fallback system:
|
||||
|
||||
1. **Loads `config.toml`** if present in the extension directory
|
||||
2. **Falls back to defaults** if configuration fails or file is missing
|
||||
3. **Only fails** if both config and defaults cannot establish connection
|
||||
|
||||
**Default Settings:**
|
||||
|
||||
- **Host**: `127.0.0.1`
|
||||
- **Port**: `6379`
|
||||
- **Max Connections**: `10`
|
||||
@ -101,6 +107,7 @@ The extension uses a robust fallback system:
|
||||
### Common Configurations
|
||||
|
||||
**Development (Local Redis)**:
|
||||
|
||||
```toml
|
||||
[redis]
|
||||
host = "127.0.0.1"
|
||||
@ -110,6 +117,7 @@ min_connections = 1
|
||||
```
|
||||
|
||||
**Production (Remote Redis with Authentication)**:
|
||||
|
||||
```toml
|
||||
[redis]
|
||||
host = "redis.example.com"
|
||||
@ -124,20 +132,24 @@ idle_timeout = 60
|
||||
### Troubleshooting
|
||||
|
||||
**Connection Issues:**
|
||||
|
||||
- Verify Redis server is running: `redis-cli ping`
|
||||
- Check host/port settings in `config.toml`
|
||||
- Ensure firewall allows connection
|
||||
|
||||
**Authentication Issues:**
|
||||
|
||||
- Verify username/password in config
|
||||
- Check Redis server auth settings
|
||||
|
||||
**Config File Issues:**
|
||||
|
||||
- Check TOML syntax with online validators
|
||||
- Ensure quotes are properly closed
|
||||
- Verify file permissions
|
||||
|
||||
**Connection Pool Benefits:**
|
||||
|
||||
- Pre-warmed connections for zero-latency operations
|
||||
- Automatic connection recovery on network issues
|
||||
- Resource-efficient connection sharing
|
||||
@ -146,23 +158,24 @@ idle_timeout = 60
|
||||
## 🔧 Installation
|
||||
|
||||
1. **Prerequisites**:
|
||||
- Redis server (local or remote)
|
||||
- Arma 3 server with extension support
|
||||
- Redis server (local or remote)
|
||||
- Arma 3 server with extension support
|
||||
|
||||
2. **Extension Setup**:
|
||||
- Build the extension: `cargo build --release`
|
||||
- Copy the compiled `forge_server_x64.dll` to your Arma 3 server
|
||||
- Copy `config.example.toml` to `config.toml` and configure as needed
|
||||
- Load in server config or mission
|
||||
- Build the extension: `cargo build --release`
|
||||
- Copy the compiled `forge_server_x64.dll` to your Arma 3 server
|
||||
- Copy `config.example.toml` to `config.toml` and configure as needed
|
||||
- Load in server config or mission
|
||||
|
||||
3. **Redis Server**:
|
||||
```bash
|
||||
# Start Redis server
|
||||
redis-server
|
||||
|
||||
# Verify connection
|
||||
redis-cli ping
|
||||
```
|
||||
|
||||
```bash
|
||||
# Start Redis server
|
||||
redis-server
|
||||
|
||||
# Verify connection
|
||||
redis-cli ping
|
||||
```
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
@ -184,6 +197,7 @@ idle_timeout = 60
|
||||
This module returns **raw Redis responses** as strings for maximum performance:
|
||||
|
||||
### Success Responses
|
||||
|
||||
- **String values**: `"John"` (raw string)
|
||||
- **Numbers**: `"42"` (number as string)
|
||||
- **Lists/Arrays**: `"item1,item2,item3"` (comma-separated)
|
||||
@ -192,12 +206,14 @@ This module returns **raw Redis responses** as strings for maximum performance:
|
||||
- **Status**: `"OK"` (for successful SET operations)
|
||||
|
||||
### Error Responses
|
||||
|
||||
- **Format**: `"Error: <error description>"`
|
||||
- **Pool errors**: `"Error: Redis pool not initialized"`
|
||||
- **Connection errors**: `"Error: <connection error>"`
|
||||
- **Redis errors**: `"Error: <Redis operation error>"`
|
||||
|
||||
### Higher-Level JSON Formatting
|
||||
|
||||
Game modules (actor, garage, etc.) will wrap these raw responses in structured JSON for SQF consumption.
|
||||
|
||||
## ⚙️ Macro-Based Implementation
|
||||
@ -220,7 +236,7 @@ pub fn set_key(key: String, value: String) -> String {
|
||||
### What the Macro Handles
|
||||
|
||||
- **Pool Management**: Retrieves Redis connection pool
|
||||
- **Error Handling**: Returns "Error: ..." for pool/connection failures
|
||||
- **Error Handling**: Returns "Error: ..." for pool/connection failures
|
||||
- **Async Bridging**: Uses shared Tokio runtime via `block_on()`
|
||||
- **Connection Acquisition**: Gets connection from pool with error handling
|
||||
- **Cleanup**: Automatic connection return to pool
|
||||
@ -238,19 +254,19 @@ pub fn set_key(key: String, value: String) -> String {
|
||||
- **Language**: Rust (Edition 2024)
|
||||
- **Dependencies**: arma-rs, bb8-redis, redis, tokio
|
||||
- **Architecture**: Macro-based design with single runtime and connection pool
|
||||
- **Key Patterns**:
|
||||
- Global state management in `lib.rs`
|
||||
- Boilerplate elimination via `redis_operation!` macro
|
||||
- Synchronous interfaces over async operations
|
||||
- Raw Redis responses for minimal overhead
|
||||
- **Key Patterns**:
|
||||
- Global state management in `lib.rs`
|
||||
- Boilerplate elimination via `redis_operation!` macro
|
||||
- Synchronous interfaces over async operations
|
||||
- Raw Redis responses for minimal overhead
|
||||
- **Testing**: Unit tests for core functionality
|
||||
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
The extension provides comprehensive error handling:
|
||||
|
||||
- Connection failures
|
||||
- Redis operation errors
|
||||
- Redis operation errors
|
||||
- Invalid parameters
|
||||
- Pool initialization errors
|
||||
|
||||
@ -259,10 +275,11 @@ All errors include descriptive messages for debugging.
|
||||
## 🔍 Monitoring
|
||||
|
||||
Connection pool status and Redis operations can be monitored through:
|
||||
|
||||
- Extension logs
|
||||
- Redis server logs
|
||||
- Redis server logs
|
||||
- Connection pool metrics
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for the Arma 3 community**
|
||||
**Built with ❤️ for the Arma 3 community**
|
||||
|
||||
@ -7,6 +7,7 @@ Complete reference for **raw Redis operations** available in the Forge Server ex
|
||||
## 🏗️ Implementation
|
||||
|
||||
All Redis operations are implemented using the `redis_operation!` macro for:
|
||||
|
||||
- **Consistent Error Handling**: All functions return identical error formats
|
||||
- **Connection Management**: Automatic pool and connection handling
|
||||
- **Synchronous Interface**: Functions block until Redis operations complete
|
||||
@ -19,6 +20,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
## 📝 Common Operations
|
||||
|
||||
### SET - Store a key-value pair
|
||||
|
||||
**Command**: `redis:common:set`
|
||||
**Parameters**: `[key, value]`
|
||||
|
||||
@ -29,6 +31,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"OK"`
|
||||
|
||||
### GET - Retrieve a value by key
|
||||
|
||||
**Command**: `redis:common:get`
|
||||
**Parameters**: `[key]`
|
||||
|
||||
@ -39,6 +42,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"John"` (the actual stored value)
|
||||
|
||||
### INCR - Increment a numeric value
|
||||
|
||||
**Command**: `redis:common:incr`
|
||||
**Parameters**: `[key, increment_amount]`
|
||||
|
||||
@ -49,6 +53,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"110"` (the new value as string)
|
||||
|
||||
### DECR - Decrement a numeric value
|
||||
|
||||
**Command**: `redis:common:decr`
|
||||
**Parameters**: `[key, decrement_amount]`
|
||||
|
||||
@ -59,6 +64,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"2"` (the new value as string)
|
||||
|
||||
### DEL - Delete a key
|
||||
|
||||
**Command**: `redis:common:del`
|
||||
**Parameters**: `[key]`
|
||||
|
||||
@ -69,8 +75,9 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"1"` (number of keys deleted)
|
||||
|
||||
### KEYS - List all keys matching pattern
|
||||
|
||||
**Command**: `redis:common:keys`
|
||||
**Parameters**: `[]` (currently returns all keys with "*" pattern)
|
||||
**Parameters**: `[]` (currently returns all keys with "\*" pattern)
|
||||
|
||||
```sqf
|
||||
"forge_server" callExtension ["redis:common:keys", []]
|
||||
@ -81,6 +88,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
## 🗂️ Hash Operations
|
||||
|
||||
### HSET - Set hash field
|
||||
|
||||
**Command**: `redis:hash:set`
|
||||
**Parameters**: `[hash_key, field, value]`
|
||||
|
||||
@ -91,6 +99,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"1"` (number of fields added)
|
||||
|
||||
### HGET - Get hash field
|
||||
|
||||
**Command**: `redis:hash:get`
|
||||
**Parameters**: `[hash_key, field]`
|
||||
|
||||
@ -101,6 +110,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"John"` (the field value)
|
||||
|
||||
### HMSET - Set multiple hash fields
|
||||
|
||||
**Command**: `redis:hash:mset`
|
||||
**Parameters**: `[hash_key, [[field1, value1], [field2, value2], ...]]`
|
||||
|
||||
@ -111,6 +121,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"OK"`
|
||||
|
||||
### HGETALL - Get all hash fields
|
||||
|
||||
**Command**: `redis:hash:getall`
|
||||
**Parameters**: `[hash_key]`
|
||||
|
||||
@ -121,6 +132,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"name,John,score,100,level,5"` (comma-separated key-value pairs)
|
||||
|
||||
### HDEL - Delete hash field
|
||||
|
||||
**Command**: `redis:hash:del`
|
||||
**Parameters**: `[hash_key, field]`
|
||||
|
||||
@ -131,6 +143,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"1"` (number of fields removed)
|
||||
|
||||
### HKEYS - Get all hash field names
|
||||
|
||||
**Command**: `redis:hash:keys`
|
||||
**Parameters**: `[hash_key]`
|
||||
|
||||
@ -141,6 +154,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"name,score,level"` (comma-separated field names)
|
||||
|
||||
### HVALS - Get all hash values
|
||||
|
||||
**Command**: `redis:hash:vals`
|
||||
**Parameters**: `[hash_key]`
|
||||
|
||||
@ -151,6 +165,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"John,100,5"` (comma-separated values)
|
||||
|
||||
### HLEN - Get hash field count
|
||||
|
||||
**Command**: `redis:hash:len`
|
||||
**Parameters**: `[hash_key]`
|
||||
|
||||
@ -163,6 +178,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
## 📋 List Operations
|
||||
|
||||
### LSET - Set list element by index
|
||||
|
||||
**Command**: `redis:list:set`
|
||||
**Parameters**: `[list_key, index, value]`
|
||||
|
||||
@ -173,6 +189,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"OK"`
|
||||
|
||||
### LGET - Get list element by index
|
||||
|
||||
**Command**: `redis:list:get`
|
||||
**Parameters**: `[list_key, index]`
|
||||
|
||||
@ -183,6 +200,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"patrol_alpha"` (the element value)
|
||||
|
||||
### LLEN - Get list length
|
||||
|
||||
**Command**: `redis:list:len`
|
||||
**Parameters**: `[list_key]`
|
||||
|
||||
@ -193,6 +211,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"5"` (list length)
|
||||
|
||||
### LRANGE - Get list elements in range
|
||||
|
||||
**Command**: `redis:list:range`
|
||||
**Parameters**: `[list_key, start_index, end_index]`
|
||||
|
||||
@ -203,6 +222,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"patrol_alpha,escort_beta,defend_gamma"` (comma-separated values)
|
||||
|
||||
### LPUSH - Add element to list head
|
||||
|
||||
**Command**: `redis:list:lpush`
|
||||
**Parameters**: `[list_key, value]`
|
||||
|
||||
@ -213,6 +233,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"6"` (new list length)
|
||||
|
||||
### RPUSH - Add element to list tail
|
||||
|
||||
**Command**: `redis:list:rpush`
|
||||
**Parameters**: `[list_key, value]`
|
||||
|
||||
@ -223,6 +244,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"6"` (new list length)
|
||||
|
||||
### LPOP - Remove and return element from list head
|
||||
|
||||
**Command**: `redis:list:lpop`
|
||||
**Parameters**: `[list_key, count]`
|
||||
|
||||
@ -233,6 +255,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"player_joined"` (removed element) or `"item1,item2"` (if count > 1)
|
||||
|
||||
### RPOP - Remove and return element from list tail
|
||||
|
||||
**Command**: `redis:list:rpop`
|
||||
**Parameters**: `[list_key, count]`
|
||||
|
||||
@ -243,6 +266,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"new_objective"` (removed element) or `"item1,item2"` (if count > 1)
|
||||
|
||||
### LTRIM - Trim list to specified range
|
||||
|
||||
**Command**: `redis:list:trim`
|
||||
**Parameters**: `[list_key, start_index, end_index]`
|
||||
|
||||
@ -253,6 +277,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"OK"`
|
||||
|
||||
### LREM - Remove elements from list
|
||||
|
||||
**Command**: `redis:list:del`
|
||||
**Parameters**: `[list_key, count, value]`
|
||||
|
||||
@ -265,6 +290,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
## 🎯 Set Operations
|
||||
|
||||
### SADD - Add element to set
|
||||
|
||||
**Command**: `redis:set:add`
|
||||
**Parameters**: `[set_key, value]`
|
||||
|
||||
@ -275,6 +301,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"1"` (1 if element was added, 0 if already existed)
|
||||
|
||||
### SMEMBERS - Get all set members
|
||||
|
||||
**Command**: `redis:set:members`
|
||||
**Parameters**: `[set_key]`
|
||||
|
||||
@ -285,6 +312,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"player_123,player_456,player_789"` (comma-separated members)
|
||||
|
||||
### SCARD - Get set size
|
||||
|
||||
**Command**: `redis:set:card`
|
||||
**Parameters**: `[set_key]`
|
||||
|
||||
@ -295,6 +323,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"3"` (number of elements in set)
|
||||
|
||||
### SREM - Remove element from set
|
||||
|
||||
**Command**: `redis:set:del`
|
||||
**Parameters**: `[set_key, value]`
|
||||
|
||||
@ -305,6 +334,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"1"` (1 if element was removed, 0 if didn't exist)
|
||||
|
||||
### SISMEMBER - Check if element is in set
|
||||
|
||||
**Command**: `redis:set:ismember`
|
||||
**Parameters**: `[set_key, value]`
|
||||
|
||||
@ -315,6 +345,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"1"` (1 if member exists, 0 if not)
|
||||
|
||||
### SPOP - Remove and return random element
|
||||
|
||||
**Command**: `redis:set:pop`
|
||||
**Parameters**: `[set_key]`
|
||||
|
||||
@ -325,6 +356,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"mission_alpha"` (the removed element)
|
||||
|
||||
### SRANDMEMBER - Get random element without removing
|
||||
|
||||
**Command**: `redis:set:randmember`
|
||||
**Parameters**: `[set_key]`
|
||||
|
||||
@ -335,6 +367,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
||||
**Raw Response**: `"mission_beta"` (a random element)
|
||||
|
||||
### SRANDMEMBER - Get multiple random elements
|
||||
|
||||
**Command**: `redis:set:randmembers`
|
||||
**Parameters**: `[set_key, count]`
|
||||
|
||||
@ -351,6 +384,7 @@ All commands may return error responses in this format:
|
||||
**Raw Error Response**: `"Error: <description>"`
|
||||
|
||||
### Common Error Types
|
||||
|
||||
- **Pool not initialized**: `"Error: Redis pool not initialized"`
|
||||
- **Connection failed**: `"Error: Connection refused (os error 61)"`
|
||||
- **Key not found**: `"Error: key not found"` (for operations on non-existent keys)
|
||||
@ -358,16 +392,18 @@ All commands may return error responses in this format:
|
||||
- **Index out of range**: `"Error: index out of range"` (for list operations)
|
||||
|
||||
### Error Handling in Game Modules
|
||||
|
||||
Higher-level game modules should check if the response starts with `"Error: "` to distinguish between successful responses and errors.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error": "Failed to connect to Redis server"
|
||||
"status": "error",
|
||||
"error": "Failed to connect to Redis server"
|
||||
}
|
||||
```
|
||||
|
||||
Common error types:
|
||||
|
||||
- **Connection errors**: Redis server unavailable
|
||||
- **Operation errors**: Invalid data type for operation
|
||||
- **Parameter errors**: Missing or invalid parameters
|
||||
@ -376,11 +412,13 @@ Common error types:
|
||||
## 📊 Response Fields
|
||||
|
||||
### Common Fields
|
||||
|
||||
- `status`: Always present - "success" or "error"
|
||||
- `key`: The Redis key being operated on
|
||||
- `error`: Error message (only on error responses)
|
||||
|
||||
### Success-Specific Fields
|
||||
|
||||
- `data`: The retrieved data (for GET operations)
|
||||
- `value`: The stored value (for SET operations)
|
||||
- `was_new`: Boolean indicating if operation created new data
|
||||
|
||||
@ -7,6 +7,7 @@ Practical examples of using the **raw Redis client module** as a foundation for
|
||||
## 🚀 Function Behavior
|
||||
|
||||
All Redis functions are **synchronous from SQF's perspective**:
|
||||
|
||||
- Functions **block** until Redis operation completes
|
||||
- **No callbacks** or async handling needed in SQF
|
||||
- **Direct return values** – either data or error strings
|
||||
@ -17,6 +18,7 @@ The extension handles all async complexity internally using a macro-based archit
|
||||
## 🎮 Player Management
|
||||
|
||||
### Player Join/Leave Tracking
|
||||
|
||||
```sqf
|
||||
// When player joins
|
||||
_playerUID = getPlayerUID player;
|
||||
@ -35,15 +37,16 @@ _playerName = name player;
|
||||
```
|
||||
|
||||
### Player Statistics System
|
||||
|
||||
```sqf
|
||||
// Initialize player stats
|
||||
fnc_initPlayerStats = {
|
||||
params ["_playerUID"];
|
||||
|
||||
|
||||
_playerKey = format ["stats:%1", _playerUID];
|
||||
"forge_server" callExtension ["redis:hash:mset", [_playerKey, [
|
||||
["kills", "0"],
|
||||
["deaths", "0"],
|
||||
["deaths", "0"],
|
||||
["score", "0"],
|
||||
["playtime", "0"]
|
||||
]]];
|
||||
@ -52,7 +55,7 @@ fnc_initPlayerStats = {
|
||||
// Update player kill
|
||||
fnc_addPlayerKill = {
|
||||
params ["_playerUID"];
|
||||
|
||||
|
||||
_playerKey = format ["stats:%1", _playerUID];
|
||||
"forge_server" callExtension ["redis:hash:incr", [_playerKey, "kills", 1]];
|
||||
"forge_server" callExtension ["redis:hash:incr", [_playerKey, "score", 10]];
|
||||
@ -61,11 +64,11 @@ fnc_addPlayerKill = {
|
||||
// Get player stats (raw response)
|
||||
fnc_getPlayerStats = {
|
||||
params ["_playerUID"];
|
||||
|
||||
|
||||
_playerKey = format ["stats:%1", _playerUID];
|
||||
_rawResult = "forge_server" callExtension ["redis:hash:getall", [_playerKey]];
|
||||
// _rawResult is now "kills,15,deaths,3,score,150,playtime,7200"
|
||||
|
||||
|
||||
// Game modules would parse this into structured data
|
||||
// For now, return raw comma-separated response
|
||||
_rawResult select 0;
|
||||
@ -75,14 +78,15 @@ fnc_getPlayerStats = {
|
||||
## 🏆 Leaderboards and Rankings
|
||||
|
||||
### Global Kill Leaderboard
|
||||
|
||||
```sqf
|
||||
// Add score to sorted leaderboard (using list for simplicity)
|
||||
fnc_updateLeaderboard = {
|
||||
params ["_playerName", "_kills"];
|
||||
|
||||
|
||||
// Store individual score
|
||||
"forge_server" callExtension ["redis:common:set", [format ["kills:%1", _playerName], str _kills]];
|
||||
|
||||
|
||||
// Add to leaderboard tracking
|
||||
"forge_server" callExtension ["redis:set:add", ["leaderboard_players", _playerName]];
|
||||
};
|
||||
@ -92,29 +96,29 @@ fnc_getTopPlayers = {
|
||||
// Get all leaderboard players - returns comma-separated list
|
||||
_playersResult = "forge_server" callExtension ["redis:set:members", ["leaderboard_players"]];
|
||||
_rawPlayers = _playersResult select 0;
|
||||
|
||||
|
||||
// Check for error
|
||||
if (_rawPlayers find "Error:" == 0) exitWith { [] };
|
||||
|
||||
|
||||
// Split comma-separated player list
|
||||
_players = _rawPlayers splitString ",";
|
||||
_scoreArray = [];
|
||||
|
||||
|
||||
// Get scores for all players
|
||||
{
|
||||
_killsResult = "forge_server" callExtension ["redis:common:get", [format ["kills:%1", _x]]];
|
||||
_rawKills = _killsResult select 0;
|
||||
|
||||
|
||||
// Check for valid response (not an error)
|
||||
if (_rawKills find "Error:" != 0) then {
|
||||
_scoreArray pushBack [_x, parseNumber _rawKills];
|
||||
};
|
||||
} forEach _players;
|
||||
|
||||
|
||||
// Sort by score (highest first)
|
||||
_scoreArray sort false;
|
||||
_scoreArray resize (10 min (count _scoreArray)); // Top 10
|
||||
|
||||
|
||||
_scoreArray;
|
||||
};
|
||||
```
|
||||
@ -122,13 +126,14 @@ fnc_getTopPlayers = {
|
||||
## 🎯 Mission State Management
|
||||
|
||||
### Objective System
|
||||
|
||||
```sqf
|
||||
// Set mission objectives
|
||||
fnc_initMissionObjectives = {
|
||||
"forge_server" callExtension ["redis:list:rpush", ["objectives", "Secure Alpha Base"]];
|
||||
"forge_server" callExtension ["redis:list:rpush", ["objectives", "Extract Intel"]];
|
||||
"forge_server" callExtension ["redis:list:rpush", ["objectives", "Eliminate HVT"]];
|
||||
|
||||
|
||||
// Set current objective pointer
|
||||
"forge_server" callExtension ["redis:common:set", ["current_objective", "0"]];
|
||||
};
|
||||
@ -138,24 +143,24 @@ fnc_completeObjective = {
|
||||
// Get current objective index - returns raw string
|
||||
_indexResult = "forge_server" callExtension ["redis:common:get", ["current_objective"]];
|
||||
_rawIndex = _indexResult select 0;
|
||||
|
||||
|
||||
// Check for error
|
||||
if (_rawIndex find "Error:" == 0) exitWith {};
|
||||
|
||||
|
||||
_currentIndex = parseNumber _rawIndex;
|
||||
|
||||
|
||||
// Get objective name - returns raw string
|
||||
_objResult = "forge_server" callExtension ["redis:list:get", ["objectives", _currentIndex]];
|
||||
_objectiveName = _objResult select 0;
|
||||
|
||||
|
||||
// Check for valid response
|
||||
if (_objectiveName find "Error:" != 0) then {
|
||||
// Move to completed objectives - returns new list length
|
||||
"forge_server" callExtension ["redis:list:rpush", ["completed_objectives", _objectiveName]];
|
||||
|
||||
|
||||
// Move to next objective - returns "OK"
|
||||
"forge_server" callExtension ["redis:common:set", ["current_objective", str (_currentIndex + 1)]];
|
||||
|
||||
|
||||
// Broadcast completion
|
||||
[format ["Objective Complete: %1", _objectiveName]] remoteExec ["hint"];
|
||||
};
|
||||
@ -165,18 +170,18 @@ fnc_completeObjective = {
|
||||
fnc_getMissionProgress = {
|
||||
_totalResult = "forge_server" callExtension ["redis:list:len", ["objectives"]];
|
||||
_completedResult = "forge_server" callExtension ["redis:list:len", ["completed_objectives"]];
|
||||
|
||||
|
||||
_rawTotal = _totalResult select 0;
|
||||
_rawCompleted = _completedResult select 0;
|
||||
|
||||
|
||||
// Check for errors
|
||||
if (_rawTotal find "Error:" == 0 || _rawCompleted find "Error:" == 0) exitWith {
|
||||
"Mission Progress: Unknown";
|
||||
};
|
||||
|
||||
|
||||
_total = parseNumber _rawTotal;
|
||||
_completed = parseNumber _rawCompleted;
|
||||
|
||||
|
||||
format ["Mission Progress: %1/%2 objectives completed", _completed, _total];
|
||||
};
|
||||
```
|
||||
@ -184,11 +189,12 @@ fnc_getMissionProgress = {
|
||||
## 🚁 Vehicle and Equipment Tracking
|
||||
|
||||
### Vehicle Pool System
|
||||
|
||||
```sqf
|
||||
// Initialize vehicle pool
|
||||
fnc_initVehiclePool = {
|
||||
params ["_vehicleClass", "_count"];
|
||||
|
||||
|
||||
for "_i" from 1 to _count do {
|
||||
_vehicleId = format ["%1_%2", _vehicleClass, _i];
|
||||
"forge_server" callExtension ["redis:set:add", ["available_vehicles", _vehicleId]];
|
||||
@ -203,19 +209,19 @@ fnc_initVehiclePool = {
|
||||
// Request vehicle
|
||||
fnc_requestVehicle = {
|
||||
params ["_playerUID"];
|
||||
|
||||
|
||||
// Get random available vehicle
|
||||
_result = "forge_server" callExtension ["redis:set:pop", ["available_vehicles"]];
|
||||
_data = fromJSON (_result select 0);
|
||||
|
||||
|
||||
if ((_data select "status") == "success") then {
|
||||
_vehicleId = _data select "data";
|
||||
|
||||
|
||||
// Mark as in use
|
||||
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "in_use"]];
|
||||
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "user", _playerUID]];
|
||||
"forge_server" callExtension ["redis:set:add", ["used_vehicles", _vehicleId]];
|
||||
|
||||
|
||||
_vehicleId;
|
||||
} else {
|
||||
""; // No vehicles available
|
||||
@ -225,10 +231,10 @@ fnc_requestVehicle = {
|
||||
// Return vehicle
|
||||
fnc_returnVehicle = {
|
||||
params ["_vehicleId", "_condition"];
|
||||
|
||||
|
||||
// Update condition
|
||||
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "condition", str _condition]];
|
||||
|
||||
|
||||
// Return to pool if condition is good
|
||||
if (_condition > 50) then {
|
||||
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "available"]];
|
||||
@ -245,39 +251,40 @@ fnc_returnVehicle = {
|
||||
## 📊 Server Analytics
|
||||
|
||||
### Player Session Tracking
|
||||
|
||||
```sqf
|
||||
// Track player session start
|
||||
fnc_startPlayerSession = {
|
||||
params ["_playerUID"];
|
||||
|
||||
|
||||
_sessionId = format ["%1_%2", _playerUID, floor time];
|
||||
_sessionKey = format ["session:%1", _sessionId];
|
||||
|
||||
|
||||
"forge_server" callExtension ["redis:hash:mset", [_sessionKey, [
|
||||
["player_uid", _playerUID],
|
||||
["start_time", str time],
|
||||
["server_id", serverName],
|
||||
["player_count", str (count allPlayers)]
|
||||
]]];
|
||||
|
||||
|
||||
// Store current session for player
|
||||
"forge_server" callExtension ["redis:common:set", [format ["current_session:%1", _playerUID], _sessionId]];
|
||||
|
||||
|
||||
_sessionId;
|
||||
};
|
||||
|
||||
// End player session
|
||||
fnc_endPlayerSession = {
|
||||
params ["_playerUID", "_sessionStats"];
|
||||
|
||||
|
||||
// Get current session
|
||||
_result = "forge_server" callExtension ["redis:common:get", [format ["current_session:%1", _playerUID]]];
|
||||
_data = fromJSON (_result select 0);
|
||||
|
||||
|
||||
if ((_data select "status") == "success") then {
|
||||
_sessionId = _data select "data";
|
||||
_sessionKey = format ["session:%1", _sessionId];
|
||||
|
||||
|
||||
// Update session with end data
|
||||
"forge_server" callExtension ["redis:hash:mset", [_sessionKey, [
|
||||
["end_time", str time],
|
||||
@ -285,7 +292,7 @@ fnc_endPlayerSession = {
|
||||
["kills", str (_sessionStats select "kills")],
|
||||
["deaths", str (_sessionStats select "deaths")]
|
||||
]]];
|
||||
|
||||
|
||||
// Clean up current session tracking
|
||||
"forge_server" callExtension ["redis:common:del", [format ["current_session:%1", _playerUID]]];
|
||||
};
|
||||
@ -295,17 +302,18 @@ fnc_endPlayerSession = {
|
||||
## 🔄 Cross-Server Communication
|
||||
|
||||
### Message Queue System
|
||||
|
||||
```sqf
|
||||
// Send message to other servers
|
||||
fnc_sendCrossServerMessage = {
|
||||
params ["_targetServer", "_messageType", "_messageData"];
|
||||
|
||||
|
||||
_message = createHashMap;
|
||||
_message set ["from_server", serverName];
|
||||
_message set ["type", _messageType];
|
||||
_message set ["data", _messageData];
|
||||
_message set ["timestamp", str time];
|
||||
|
||||
|
||||
_queueKey = format ["messages:%1", _targetServer];
|
||||
"forge_server" callExtension ["redis:list:rpush", [_queueKey, str _message]];
|
||||
};
|
||||
@ -313,21 +321,21 @@ fnc_sendCrossServerMessage = {
|
||||
// Check for incoming messages
|
||||
fnc_checkMessages = {
|
||||
_queueKey = format ["messages:%1", serverName];
|
||||
|
||||
|
||||
// Get next message
|
||||
_result = "forge_server" callExtension ["redis:list:lpop", [_queueKey, 1]];
|
||||
_data = fromJSON (_result select 0);
|
||||
|
||||
|
||||
if ((_data select "status") == "success") then {
|
||||
_messages = _data select "data";
|
||||
if (count _messages > 0) then {
|
||||
_messageStr = _messages select 0;
|
||||
_message = fromJSON _messageStr;
|
||||
|
||||
|
||||
// Process message based on type
|
||||
_type = _message select "type";
|
||||
_messageData = _message select "data";
|
||||
|
||||
|
||||
switch (_type) do {
|
||||
case "player_transfer": {
|
||||
[_messageData] call fnc_handlePlayerTransfer;
|
||||
@ -355,11 +363,12 @@ fnc_checkMessages = {
|
||||
## 🛠️ Utility Functions
|
||||
|
||||
### Redis Helper Functions
|
||||
|
||||
```sqf
|
||||
// Parse Redis response safely
|
||||
fnc_parseRedisResponse = {
|
||||
params ["_response"];
|
||||
|
||||
|
||||
try {
|
||||
_data = fromJSON (_response select 0);
|
||||
if ((_data select "status") == "success") then {
|
||||
@ -377,14 +386,14 @@ fnc_parseRedisResponse = {
|
||||
// Batch Redis operations
|
||||
fnc_redisBatch = {
|
||||
params ["_operations"];
|
||||
|
||||
|
||||
_results = [];
|
||||
{
|
||||
_op = _x;
|
||||
_result = "forge_server" callExtension [_op select 0, _op select 1];
|
||||
_results pushBack (fromJSON (_result select 0));
|
||||
} forEach _operations;
|
||||
|
||||
|
||||
_results;
|
||||
};
|
||||
|
||||
@ -400,14 +409,15 @@ _results = [_batchOps] call fnc_redisBatch;
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
```sqf
|
||||
fnc_safeRedisCall = {
|
||||
params ["_command", "_params", ["_defaultValue", nil]];
|
||||
|
||||
|
||||
try {
|
||||
_result = "forge_server" callExtension [_command, _params];
|
||||
_data = fromJSON (_result select 0);
|
||||
|
||||
|
||||
if ((_data select "status") == "success") then {
|
||||
_data select "data";
|
||||
} else {
|
||||
@ -424,4 +434,4 @@ fnc_safeRedisCall = {
|
||||
_playerName = ["redis:common:get", ["player_name"], "Unknown"] call fnc_safeRedisCall;
|
||||
```
|
||||
|
||||
These examples demonstrate real-world usage patterns for the Redis extension in Arma 3 environments, covering player management, mission state, analytics, and cross-server communication.
|
||||
These examples demonstrate real-world usage patterns for the Redis extension in Arma 3 environments, covering player management, mission state, analytics, and cross-server communication.
|
||||
|
||||
@ -26,16 +26,16 @@ The Organization module handles guild/clan management, allowing players to form
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `org:get` | Retrieve organization data by key or ID. |
|
||||
| `org:create` | Create a new organization with provided JSON data. |
|
||||
| `org:update` | Update an existing organization with partial JSON data. |
|
||||
| `org:delete` | Permanently remove an organization and its data. |
|
||||
| `org:exists` | Check if an organization exists. |
|
||||
| `org:get_members` | Retrieve a list of organization members. |
|
||||
| `org:add_member` | Add a member to an organization. |
|
||||
| `org:remove_member` | Remove a member from an organization. |
|
||||
| Command | Description |
|
||||
| ------------------- | ------------------------------------------------------- |
|
||||
| `org:get` | Retrieve organization data by key or ID. |
|
||||
| `org:create` | Create a new organization with provided JSON data. |
|
||||
| `org:update` | Update an existing organization with partial JSON data. |
|
||||
| `org:delete` | Permanently remove an organization and its data. |
|
||||
| `org:exists` | Check if an organization exists. |
|
||||
| `org:get_members` | Retrieve a list of organization members. |
|
||||
| `org:add_member` | Add a member to an organization. |
|
||||
| `org:remove_member` | Remove a member from an organization. |
|
||||
|
||||
### SQF Examples
|
||||
|
||||
@ -128,13 +128,13 @@ The Actor module handles all player-related operations, including data retrieval
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `actor:get` | Retrieve actor data by key or UID. |
|
||||
| `actor:create` | Create a new actor with provided JSON data. |
|
||||
| Command | Description |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| `actor:get` | Retrieve actor data by key or UID. |
|
||||
| `actor:create` | Create a new actor with provided JSON data. |
|
||||
| `actor:update` | Update an existing actor with partial JSON data. |
|
||||
| `actor:exists` | Check if an actor exists in the database. |
|
||||
| `actor:delete` | Permanently remove an actor and their data. |
|
||||
| `actor:exists` | Check if an actor exists in the database. |
|
||||
| `actor:delete` | Permanently remove an actor and their data. |
|
||||
|
||||
### SQF Examples
|
||||
|
||||
@ -249,6 +249,7 @@ We welcome contributions to the Forge Extension! This guide will help you unders
|
||||
To add a new command to an existing module (e.g., `actor:set_position`), follow these steps:
|
||||
|
||||
1. **Register the Command**: In the module file (e.g., `src/actor.rs`), add the command to the `group()` function.
|
||||
|
||||
```rust
|
||||
pub fn group() -> Group {
|
||||
Group::new()
|
||||
@ -262,6 +263,7 @@ To add a new command to an existing module (e.g., `actor:set_position`), follow
|
||||
```
|
||||
|
||||
2. **Implement the Handler Function**: Create the function that handles the command logic.
|
||||
|
||||
```rust
|
||||
use crate::log::log;
|
||||
|
||||
@ -293,7 +295,7 @@ To add a new command to an existing module (e.g., `actor:set_position`), follow
|
||||
match ACTOR_SERVICE.get_actor(resolved_uid.clone()) {
|
||||
Ok(mut actor) => {
|
||||
actor.set_position(position_data);
|
||||
|
||||
|
||||
match ACTOR_SERVICE.update_actor(actor.clone()) {
|
||||
Ok(_) => {
|
||||
log("actor", "INFO", &format!("Updated position for: {}", resolved_uid));
|
||||
@ -316,6 +318,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
||||
|
||||
1. **Create the Module File**: Add `src/vehicle.rs`.
|
||||
2. **Create the Global Service Instance**: Define a lazily initialized singleton service.
|
||||
|
||||
```rust
|
||||
use std::sync::LazyLock;
|
||||
use forge_services::VehicleService;
|
||||
@ -329,6 +332,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
||||
VehicleService::new(repository)
|
||||
});
|
||||
```
|
||||
|
||||
3. **Register the Command**: In the module file, register the command in the `group()` function.
|
||||
```rust
|
||||
pub fn group() -> Group {
|
||||
@ -339,6 +343,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
||||
}
|
||||
```
|
||||
4. **Use Logging**: Import and use the generic `log` function in your handler functions.
|
||||
|
||||
```rust
|
||||
use crate::log::log;
|
||||
|
||||
@ -369,7 +374,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
The `log` function takes three parameters:
|
||||
- `category`: The log category (e.g., "vehicle", "actor", "org")
|
||||
- `level`: The log level ("INFO", "DEBUG", "WARN", "ERROR")
|
||||
@ -378,9 +383,10 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
||||
Log files are created automatically in `@forge_server/logs/{category}.log`.
|
||||
|
||||
5. **Register the Module** (if new): If you created a new module, add it to `src/lib.rs`.
|
||||
|
||||
```rust
|
||||
pub mod vehicle;
|
||||
|
||||
|
||||
// In the extension function, register the group
|
||||
extension.group("vehicle", vehicle::group());
|
||||
```
|
||||
|
||||
@ -19,6 +19,7 @@ graph TD
|
||||
```
|
||||
|
||||
This design enables:
|
||||
|
||||
- **Testability**: Repositories can use mock adapters for testing
|
||||
- **Flexibility**: Different Redis implementations can be swapped without changing repositories
|
||||
- **Separation of Concerns**: Repository logic is independent of Redis connection details
|
||||
@ -38,35 +39,35 @@ The `ExtensionRedisClient` is the primary adapter that implements the `RedisClie
|
||||
|
||||
#### Hash Operations
|
||||
|
||||
| Method | Description | Returns |
|
||||
|--------|-------------|---------|
|
||||
| `hash_mset` | Set multiple fields atomically | `Result<(), String>` |
|
||||
| `hash_get_all` | Get all fields and values | `Result<String, String>` |
|
||||
| `hash_get` | Get a single field value | `Result<String, String>` |
|
||||
| `hash_del` | Delete a field | `Result<(), String>` |
|
||||
| Method | Description | Returns |
|
||||
| -------------- | ------------------------------ | ------------------------ |
|
||||
| `hash_mset` | Set multiple fields atomically | `Result<(), String>` |
|
||||
| `hash_get_all` | Get all fields and values | `Result<String, String>` |
|
||||
| `hash_get` | Get a single field value | `Result<String, String>` |
|
||||
| `hash_del` | Delete a field | `Result<(), String>` |
|
||||
|
||||
#### List Operations
|
||||
|
||||
| Method | Description | Returns |
|
||||
|--------|-------------|---------|
|
||||
| `list_rpush` | Append to list | `Result<(), String>` |
|
||||
| Method | Description | Returns |
|
||||
| ------------ | --------------------- | ----------------------------- |
|
||||
| `list_rpush` | Append to list | `Result<(), String>` |
|
||||
| `list_range` | Get range of elements | `Result<Vec<String>, String>` |
|
||||
| `list_del` | Remove by value | `Result<(), String>` |
|
||||
| `list_del` | Remove by value | `Result<(), String>` |
|
||||
|
||||
#### Set Operations
|
||||
|
||||
| Method | Description | Returns |
|
||||
|--------|-------------|---------|
|
||||
| `set_add` | Add member | `Result<(), String>` |
|
||||
| Method | Description | Returns |
|
||||
| ------------- | --------------- | ----------------------------- |
|
||||
| `set_add` | Add member | `Result<(), String>` |
|
||||
| `set_members` | Get all members | `Result<Vec<String>, String>` |
|
||||
| `set_del` | Remove member | `Result<(), String>` |
|
||||
| `set_del` | Remove member | `Result<(), String>` |
|
||||
|
||||
#### Common Operations
|
||||
|
||||
| Method | Description | Returns |
|
||||
|--------|-------------|---------|
|
||||
| Method | Description | Returns |
|
||||
| ------------ | ------------------- | ---------------------- |
|
||||
| `key_exists` | Check if key exists | `Result<bool, String>` |
|
||||
| `delete_key` | Delete key | `Result<(), String>` |
|
||||
| `delete_key` | Delete key | `Result<(), String>` |
|
||||
|
||||
### Usage Example
|
||||
|
||||
@ -119,6 +120,7 @@ We welcome contributions to the adapters module! Follow these guidelines to add
|
||||
To add a new method (e.g., `hash_exists`), follow these steps:
|
||||
|
||||
1. **Check the Trait**: Ensure the method is defined in the `RedisClient` trait in `forge_shared`.
|
||||
|
||||
```rust
|
||||
// In forge_shared/src/redis_client.rs
|
||||
pub trait RedisClient: Send + Sync {
|
||||
@ -127,6 +129,7 @@ To add a new method (e.g., `hash_exists`), follow these steps:
|
||||
```
|
||||
|
||||
2. **Implement the Method**: Add the implementation to `ExtensionRedisClient`.
|
||||
|
||||
```rust
|
||||
impl RedisClient for ExtensionRedisClient {
|
||||
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
|
||||
@ -145,6 +148,7 @@ To add a new method (e.g., `hash_exists`), follow these steps:
|
||||
```
|
||||
|
||||
3. **Add Logging** (if needed): For debugging, log the operation.
|
||||
|
||||
```rust
|
||||
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
|
||||
let result = redis::hash::hash_exists(key, field);
|
||||
@ -172,6 +176,7 @@ To create a new adapter (e.g., `MockRedisClient` for testing):
|
||||
|
||||
1. **Create the Module File**: Add `src/adapters/mock_client.rs`.
|
||||
2. **Define the Struct**: Create the adapter struct.
|
||||
|
||||
```rust
|
||||
use forge_shared::RedisClient;
|
||||
use std::collections::HashMap;
|
||||
@ -194,6 +199,7 @@ To create a new adapter (e.g., `MockRedisClient` for testing):
|
||||
```
|
||||
|
||||
3. **Implement the Trait**: Implement all `RedisClient` methods.
|
||||
|
||||
```rust
|
||||
impl RedisClient for MockRedisClient {
|
||||
fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> {
|
||||
@ -220,6 +226,7 @@ To create a new adapter (e.g., `MockRedisClient` for testing):
|
||||
```
|
||||
|
||||
4. **Register the Module**: Add to `src/adapters/mod.rs`.
|
||||
|
||||
```rust
|
||||
pub mod redis_client;
|
||||
pub mod mock_client;
|
||||
@ -235,18 +242,20 @@ To create a new adapter (e.g., `MockRedisClient` for testing):
|
||||
|
||||
**Recommended Synchronization Primitives:**
|
||||
|
||||
| Primitive | Use Case | Performance | Dependency |
|
||||
|-----------|----------|-------------|------------|
|
||||
| Primitive | Use Case | Performance | Dependency |
|
||||
| --------------------- | ---------------------------------------- | ----------------------- | ---------------- |
|
||||
| **`RwLock<HashMap>`** | Read-heavy workloads, concurrent readers | Good (multiple readers) | Standard library |
|
||||
| **`Mutex<HashMap>`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library |
|
||||
| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate |
|
||||
| **`Mutex<HashMap>`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library |
|
||||
| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate |
|
||||
|
||||
**When to use each:**
|
||||
|
||||
- **`RwLock`**: Best for most use cases. Allows multiple concurrent readers, only blocks on writes. Use this by default.
|
||||
- **`Mutex`**: Only when you need exclusive access or operations are very lightweight (< 1μs).
|
||||
- **`DashMap`**: When profiling shows `RwLock` is a bottleneck and you need lock-free performance.
|
||||
|
||||
**Why avoid `Mutex` for read-heavy workloads?**
|
||||
|
||||
- Blocks all threads (readers and writers) on every access
|
||||
- No concurrent reads possible
|
||||
- Can cause performance bottlenecks in high-concurrency scenarios
|
||||
|
||||
@ -16,6 +16,7 @@ The Redis module is organized into specialized operation groups:
|
||||
### Connection Pool
|
||||
|
||||
The module uses `bb8` for connection pooling, providing:
|
||||
|
||||
- **Automatic connection reuse**: Reduces overhead
|
||||
- **Configurable pool size**: Control max/min connections
|
||||
- **Idle timeout**: Prevents stale connections
|
||||
@ -41,14 +42,14 @@ Basic key-value operations for simple data storage.
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description | Returns |
|
||||
|---------|-------------|---------|
|
||||
| `redis:common:set` | Set a string value | "OK" |
|
||||
| `redis:common:get` | Get a string value | Value or empty string |
|
||||
| `redis:common:incr` | Increment a numeric value | New value |
|
||||
| `redis:common:decr` | Decrement a numeric value | New value |
|
||||
| `redis:common:del` | Delete a key | Number of keys removed |
|
||||
| `redis:common:keys` | List all keys | Comma-separated keys |
|
||||
| Command | Description | Returns |
|
||||
| ------------------- | ------------------------- | ---------------------- |
|
||||
| `redis:common:set` | Set a string value | "OK" |
|
||||
| `redis:common:get` | Get a string value | Value or empty string |
|
||||
| `redis:common:incr` | Increment a numeric value | New value |
|
||||
| `redis:common:decr` | Decrement a numeric value | New value |
|
||||
| `redis:common:del` | Delete a key | Number of keys removed |
|
||||
| `redis:common:keys` | List all keys | Comma-separated keys |
|
||||
|
||||
### SQF Examples
|
||||
|
||||
@ -73,17 +74,17 @@ Hash operations store structured data as field-value pairs, ideal for objects an
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description | Returns |
|
||||
|---------|-------------|---------|
|
||||
| `redis:hash:set` | Set a single field | 1 if new, 0 if updated |
|
||||
| `redis:hash:mset` | Set multiple fields atomically | "OK" |
|
||||
| `redis:hash:get` | Get a field value | Value or empty string |
|
||||
| `redis:hash:getall` | Get all fields and values | Comma-separated pairs |
|
||||
| `redis:hash:del` | Delete a field | Number of fields removed |
|
||||
| `redis:hash:keys` | Get all field names | Comma-separated keys |
|
||||
| `redis:hash:vals` | Get all values | Comma-separated values |
|
||||
| `redis:hash:len` | Get number of fields | Field count |
|
||||
| `redis:hash:exists` | Check if field exists | "1" or "0" |
|
||||
| Command | Description | Returns |
|
||||
| ------------------- | ------------------------------ | ------------------------ |
|
||||
| `redis:hash:set` | Set a single field | 1 if new, 0 if updated |
|
||||
| `redis:hash:mset` | Set multiple fields atomically | "OK" |
|
||||
| `redis:hash:get` | Get a field value | Value or empty string |
|
||||
| `redis:hash:getall` | Get all fields and values | Comma-separated pairs |
|
||||
| `redis:hash:del` | Delete a field | Number of fields removed |
|
||||
| `redis:hash:keys` | Get all field names | Comma-separated keys |
|
||||
| `redis:hash:vals` | Get all values | Comma-separated values |
|
||||
| `redis:hash:len` | Get number of fields | Field count |
|
||||
| `redis:hash:exists` | Check if field exists | "1" or "0" |
|
||||
|
||||
### SQF Examples
|
||||
|
||||
@ -118,18 +119,18 @@ List operations manage ordered collections, useful for queues, logs, and sequent
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description | Returns |
|
||||
|---------|-------------|---------|
|
||||
| `redis:list:set` | Set element at index | "OK" |
|
||||
| `redis:list:get` | Get element at index | Value (base64 decoded) |
|
||||
| `redis:list:len` | Get list length | Element count |
|
||||
| `redis:list:range` | Get range of elements | JSON array |
|
||||
| `redis:list:lpush` | Prepend to list | New length |
|
||||
| `redis:list:rpush` | Append to list | New length |
|
||||
| `redis:list:lpop` | Remove from beginning | JSON array of removed elements |
|
||||
| `redis:list:rpop` | Remove from end | JSON array of removed elements |
|
||||
| `redis:list:trim` | Trim to range | "OK" |
|
||||
| `redis:list:del` | Remove by value | Number removed |
|
||||
| Command | Description | Returns |
|
||||
| ------------------ | --------------------- | ------------------------------ |
|
||||
| `redis:list:set` | Set element at index | "OK" |
|
||||
| `redis:list:get` | Get element at index | Value (base64 decoded) |
|
||||
| `redis:list:len` | Get list length | Element count |
|
||||
| `redis:list:range` | Get range of elements | JSON array |
|
||||
| `redis:list:lpush` | Prepend to list | New length |
|
||||
| `redis:list:rpush` | Append to list | New length |
|
||||
| `redis:list:lpop` | Remove from beginning | JSON array of removed elements |
|
||||
| `redis:list:rpop` | Remove from end | JSON array of removed elements |
|
||||
| `redis:list:trim` | Trim to range | "OK" |
|
||||
| `redis:list:del` | Remove by value | Number removed |
|
||||
|
||||
### SQF Examples
|
||||
|
||||
@ -159,16 +160,16 @@ Set operations manage unique collections, perfect for membership tracking and pr
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Command | Description | Returns |
|
||||
|---------|-------------|---------|
|
||||
| `redis:set:add` | Add member to set | 1 if new, 0 if exists |
|
||||
| `redis:set:members` | Get all members | Comma-separated members |
|
||||
| `redis:set:card` | Get member count | Cardinality |
|
||||
| `redis:set:ismember` | Check membership | "1" or "0" |
|
||||
| `redis:set:randmember` | Get random member | Member value |
|
||||
| `redis:set:randmembers` | Get N random members | Comma-separated members |
|
||||
| `redis:set:pop` | Remove random member | Removed member |
|
||||
| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found |
|
||||
| Command | Description | Returns |
|
||||
| ----------------------- | ---------------------- | ---------------------------- |
|
||||
| `redis:set:add` | Add member to set | 1 if new, 0 if exists |
|
||||
| `redis:set:members` | Get all members | Comma-separated members |
|
||||
| `redis:set:card` | Get member count | Cardinality |
|
||||
| `redis:set:ismember` | Check membership | "1" or "0" |
|
||||
| `redis:set:randmember` | Get random member | Member value |
|
||||
| `redis:set:randmembers` | Get N random members | Comma-separated members |
|
||||
| `redis:set:pop` | Remove random member | Removed member |
|
||||
| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found |
|
||||
|
||||
### SQF Examples
|
||||
|
||||
@ -238,6 +239,7 @@ pub fn my_redis_command(key: String) -> String {
|
||||
```
|
||||
|
||||
The macro automatically:
|
||||
|
||||
- Acquires a connection from the pool
|
||||
- Handles lazy initialization if needed
|
||||
- Executes the operation asynchronously
|
||||
@ -246,6 +248,7 @@ The macro automatically:
|
||||
## Error Handling
|
||||
|
||||
All Redis operations return strings:
|
||||
|
||||
- **Success**: Operation result (e.g., "OK", value, count)
|
||||
- **Error**: String starting with "Error: " followed by the error message
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
"path": ".",
|
||||
},
|
||||
],
|
||||
"settings": {
|
||||
"editor.insertSpaces": true,
|
||||
@ -18,7 +18,7 @@
|
||||
"*.hpp": "arma-config",
|
||||
"*.inc": "arma-config",
|
||||
"*.cfg": "arma-config",
|
||||
"*.rvmat": "arma-config"
|
||||
}
|
||||
}
|
||||
"*.rvmat": "arma-config",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -3,10 +3,26 @@
|
||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||
const scopeAttr = "data-ui-future-card";
|
||||
const ROADMAP = [
|
||||
{ name: "Contracts Board", status: "Planned", detail: "Track payouts, assignments, and claim approvals." },
|
||||
{ name: "Diplomacy", status: "Future Review", detail: "Possible future module pending a full design and scope review." },
|
||||
{ name: "Logistics Queue", status: "Future Review", detail: "Possible future module pending a full design and scope review." },
|
||||
{ name: "Permissions", status: "Future Review", detail: "Possible future module pending a full design and scope review." },
|
||||
{
|
||||
name: "Contracts Board",
|
||||
status: "Planned",
|
||||
detail: "Track payouts, assignments, and claim approvals.",
|
||||
},
|
||||
{
|
||||
name: "Diplomacy",
|
||||
status: "Future Review",
|
||||
detail: "Possible future module pending a full design and scope review.",
|
||||
},
|
||||
{
|
||||
name: "Logistics Queue",
|
||||
status: "Future Review",
|
||||
detail: "Possible future module pending a full design and scope review.",
|
||||
},
|
||||
{
|
||||
name: "Permissions",
|
||||
status: "Future Review",
|
||||
detail: "Possible future module pending a full design and scope review.",
|
||||
},
|
||||
];
|
||||
const scopeSelector = `[${scopeAttr}]`;
|
||||
const futureCardCss = `
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user