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
|
# Contributing Setup & Guidelines
|
||||||
|
|
||||||
## Setting up the Development Environment
|
## Setting up the Development Environment
|
||||||
|
|
||||||
### 1. Clone the repository from GitHub
|
### 1. Clone the repository from GitHub
|
||||||
|
|
||||||
### 2. Install HEMTT
|
### 2. Install HEMTT
|
||||||
|
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Guidelines
|
## 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).
|
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
|
name: Bug report
|
||||||
about: Create a bug report to help us improve
|
about: Create a bug report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/bug
|
labels: kind/bug
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the bug
|
## Describe the bug
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
## To reproduce
|
## To reproduce
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
## Expected behavior
|
## Expected behavior
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest a feature to be added
|
about: Suggest a feature to be added
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/feature-request
|
labels: kind/feature-request
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the feature that you would like
|
## Describe the feature that you would like
|
||||||
|
|
||||||
A clear and concise description of the feature you'd want.
|
A clear and concise description of the feature you'd want.
|
||||||
|
|
||||||
## Possible alternatives
|
## Possible alternatives
|
||||||
|
|
||||||
Possible alternatives to your suggestion.
|
Possible alternatives to your suggestion.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the feature here.
|
Add any other context about the feature here.
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
**When merged this pull request will:**
|
**When merged this pull request will:**
|
||||||
|
|
||||||
- Describe what this pull request will do
|
- Describe what this pull request will do
|
||||||
- Each change in a separate line
|
- Each change in a separate line
|
||||||
|
|
||||||
### Important
|
### Important
|
||||||
|
|
||||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
- [ ] 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.
|
- [ ] [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}`.
|
- [ ] 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 that need to be addressed -->
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
- [ ] Issue
|
- [ ] Issue
|
||||||
|
|||||||
@ -105,15 +105,18 @@ sequenceDiagram
|
|||||||
## 🚀 **Performance Characteristics**
|
## 🚀 **Performance Characteristics**
|
||||||
|
|
||||||
### **Access Times**
|
### **Access Times**
|
||||||
|
|
||||||
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
|
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
|
||||||
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
|
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
|
||||||
- **Client Cache**: `< 0.1ms` (Local object access)
|
- **Client Cache**: `< 0.1ms` (Local object access)
|
||||||
|
|
||||||
### **Cache Hit Ratios**
|
### **Cache Hit Ratios**
|
||||||
|
|
||||||
- **Hot Cache**: `~95%` (Active players)
|
- **Hot Cache**: `~95%` (Active players)
|
||||||
- **Cold Storage**: `~5%` (New connections, cache misses)
|
- **Cold Storage**: `~5%` (New connections, cache misses)
|
||||||
|
|
||||||
### **Memory Usage**
|
### **Memory Usage**
|
||||||
|
|
||||||
- **Server Registry**: `~1KB per active player`
|
- **Server Registry**: `~1KB per active player`
|
||||||
- **Client Cache**: `~500B per player object`
|
- **Client Cache**: `~500B per player object`
|
||||||
- **Redis**: `~2KB per player (persistent)`
|
- **Redis**: `~2KB per player (persistent)`
|
||||||
|
|||||||
10
LICENSE.md
10
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:
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
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
|
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.
|
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.
|
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.
|
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||||
|
|
||||||
|
|||||||
53
README.md
53
README.md
@ -33,6 +33,7 @@ graph TD
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Communication Flow**:
|
**Communication Flow**:
|
||||||
|
|
||||||
- **Clients** → Use events (`CBA_Events`) to communicate with server
|
- **Clients** → Use events (`CBA_Events`) to communicate with server
|
||||||
- **Server** → Calls Rust extension via `callExtension`
|
- **Server** → Calls Rust extension via `callExtension`
|
||||||
- **Extension** → Manages Redis connection pool and data operations
|
- **Extension** → Manages Redis connection pool and data operations
|
||||||
@ -87,12 +88,14 @@ forge/
|
|||||||
|
|
||||||
1. Clone the repository from Gitea
|
1. Clone the repository from Gitea
|
||||||
2. Install HEMTT
|
2. Install HEMTT
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Coding Guidelines
|
### 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).
|
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
|
### Building the Extension
|
||||||
@ -143,14 +146,18 @@ private _update = createHashMapFromArray [["bank", 1500]];
|
|||||||
## Core Modules
|
## Core Modules
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
Defines strict data structures with built-in validation:
|
Defines strict data structures with built-in validation:
|
||||||
|
|
||||||
- `Actor`: Player data (stats, inventory, position)
|
- `Actor`: Player data (stats, inventory, position)
|
||||||
- `Org`: Organization/clan data (members, roles, metadata)
|
- `Org`: Organization/clan data (members, roles, metadata)
|
||||||
|
|
||||||
[Documentation](lib/models/README.md)
|
[Documentation](lib/models/README.md)
|
||||||
|
|
||||||
### Repositories
|
### Repositories
|
||||||
|
|
||||||
Manages data persistence with Redis:
|
Manages data persistence with Redis:
|
||||||
|
|
||||||
- Hash-based storage for structured data
|
- Hash-based storage for structured data
|
||||||
- Set-based storage for collections
|
- Set-based storage for collections
|
||||||
- Generic over Redis client implementations
|
- Generic over Redis client implementations
|
||||||
@ -158,7 +165,9 @@ Manages data persistence with Redis:
|
|||||||
[Documentation](lib/repositories/README.md)
|
[Documentation](lib/repositories/README.md)
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
|
|
||||||
Implements business logic and orchestration:
|
Implements business logic and orchestration:
|
||||||
|
|
||||||
- Get-or-create patterns
|
- Get-or-create patterns
|
||||||
- Data validation and transformation
|
- Data validation and transformation
|
||||||
- Complex workflows
|
- Complex workflows
|
||||||
@ -166,7 +175,9 @@ Implements business logic and orchestration:
|
|||||||
[Documentation](lib/services/README.md)
|
[Documentation](lib/services/README.md)
|
||||||
|
|
||||||
### Extension
|
### Extension
|
||||||
|
|
||||||
Arma 3 interface layer:
|
Arma 3 interface layer:
|
||||||
|
|
||||||
- Command routing and parsing
|
- Command routing and parsing
|
||||||
- Session management
|
- Session management
|
||||||
- Error handling and logging
|
- Error handling and logging
|
||||||
@ -174,7 +185,9 @@ Arma 3 interface layer:
|
|||||||
[Documentation](arma/server/extension/README.md)
|
[Documentation](arma/server/extension/README.md)
|
||||||
|
|
||||||
### Client Mod
|
### Client Mod
|
||||||
|
|
||||||
Client-side SQF addon that provides:
|
Client-side SQF addon that provides:
|
||||||
|
|
||||||
- **UI Components**: Player interfaces for inventory, organizations, banking
|
- **UI Components**: Player interfaces for inventory, organizations, banking
|
||||||
- **Event Handlers**: CBA event listeners for server communication
|
- **Event Handlers**: CBA event listeners for server communication
|
||||||
- **Optimistic Caching**: Local data caching for instant UI updates
|
- **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
|
- **Input Validation**: Client-side validation before server requests
|
||||||
|
|
||||||
The client mod communicates with the server using **CBA Events**, ensuring:
|
The client mod communicates with the server using **CBA Events**, ensuring:
|
||||||
|
|
||||||
- No direct extension calls from clients (security)
|
- No direct extension calls from clients (security)
|
||||||
- Event-driven architecture for scalability
|
- Event-driven architecture for scalability
|
||||||
- Automatic state synchronization across all clients
|
- Automatic state synchronization across all clients
|
||||||
@ -190,28 +204,32 @@ The client mod communicates with the server using **CBA Events**, ensuring:
|
|||||||
## Available Commands
|
## Available Commands
|
||||||
|
|
||||||
### Actor Commands
|
### Actor Commands
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
| Command | Description |
|
||||||
| `actor:get` | Retrieve actor data by UID |
|
| -------------- | -------------------------- |
|
||||||
| `actor:create` | Create a new actor |
|
| `actor:get` | Retrieve actor data by UID |
|
||||||
| `actor:update` | Update actor fields |
|
| `actor:create` | Create a new actor |
|
||||||
| `actor:exists` | Check if actor exists |
|
| `actor:update` | Update actor fields |
|
||||||
| `actor:delete` | Delete actor data |
|
| `actor:exists` | Check if actor exists |
|
||||||
|
| `actor:delete` | Delete actor data |
|
||||||
|
|
||||||
### Organization Commands
|
### Organization Commands
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
| Command | Description |
|
||||||
| `org:get` | Retrieve organization data |
|
| ------------------- | ------------------------------- |
|
||||||
| `org:create` | Create a new organization |
|
| `org:get` | Retrieve organization data |
|
||||||
| `org:update` | Update organization fields |
|
| `org:create` | Create a new organization |
|
||||||
| `org:exists` | Check if organization exists |
|
| `org:update` | Update organization fields |
|
||||||
| `org:delete` | Delete organization |
|
| `org:exists` | Check if organization exists |
|
||||||
| `org:add_member` | Add member to organization |
|
| `org:delete` | Delete organization |
|
||||||
|
| `org:add_member` | Add member to organization |
|
||||||
| `org:remove_member` | Remove member from organization |
|
| `org:remove_member` | Remove member from organization |
|
||||||
| `org:get_members` | Get all organization members |
|
| `org:get_members` | Get all organization members |
|
||||||
|
|
||||||
### Redis Operations
|
### Redis Operations
|
||||||
|
|
||||||
Direct Redis operations for advanced use cases:
|
Direct Redis operations for advanced use cases:
|
||||||
|
|
||||||
- **Common**: Key-value operations (set, get, incr, decr, del)
|
- **Common**: Key-value operations (set, get, incr, decr, del)
|
||||||
- **Hash**: Structured data (hset, hget, hgetall, hdel)
|
- **Hash**: Structured data (hset, hget, hgetall, hdel)
|
||||||
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
|
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
|
||||||
@ -264,6 +282,7 @@ if (_response find "Error:" == 0) then {
|
|||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Logs are automatically created in `@forge_server/logs/`:
|
Logs are automatically created in `@forge_server/logs/`:
|
||||||
|
|
||||||
- `actor.log` - Actor operations
|
- `actor.log` - Actor operations
|
||||||
- `org.log` - Organization operations
|
- `org.log` - Organization operations
|
||||||
- `redis.log` - Redis connection and 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
|
# Contributing Setup & Guidelines
|
||||||
|
|
||||||
## Setting up the Development Environment
|
## Setting up the Development Environment
|
||||||
|
|
||||||
### 1. Clone the repository from GitHub
|
### 1. Clone the repository from GitHub
|
||||||
|
|
||||||
### 2. Install HEMTT
|
### 2. Install HEMTT
|
||||||
|
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Guidelines
|
## 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).
|
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
|
name: Bug report
|
||||||
about: Create a bug report to help us improve
|
about: Create a bug report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/bug
|
labels: kind/bug
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the bug
|
## Describe the bug
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
## To reproduce
|
## To reproduce
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
## Expected behavior
|
## Expected behavior
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest a feature to be added
|
about: Suggest a feature to be added
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/feature-request
|
labels: kind/feature-request
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the feature that you would like
|
## Describe the feature that you would like
|
||||||
|
|
||||||
A clear and concise description of the feature you'd want.
|
A clear and concise description of the feature you'd want.
|
||||||
|
|
||||||
## Possible alternatives
|
## Possible alternatives
|
||||||
|
|
||||||
Possible alternatives to your suggestion.
|
Possible alternatives to your suggestion.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the feature here.
|
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:**
|
**When merged this pull request will:**
|
||||||
|
|
||||||
- Describe what this pull request will do
|
- Describe what this pull request will do
|
||||||
- Each change in a separate line
|
- Each change in a separate line
|
||||||
|
|
||||||
### Important
|
### Important
|
||||||
|
|
||||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
- [ ] 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.
|
- [ ] [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}`.
|
- [ ] 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 that need to be addressed -->
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
- [ ] Issue
|
- [ ] Issue
|
||||||
|
|||||||
24
arma/client/.github/workflows/check.yml
vendored
24
arma/client/.github/workflows/check.yml
vendored
@ -12,17 +12,17 @@ jobs:
|
|||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the source code
|
- name: Checkout the source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Validate Config
|
- name: Validate Config
|
||||||
run: python tools/config_style_checker.py
|
run: python tools/config_style_checker.py
|
||||||
- name: Check for BOM
|
- name: Check for BOM
|
||||||
uses: arma-actions/bom-check@master
|
uses: arma-actions/bom-check@master
|
||||||
with:
|
with:
|
||||||
path: "addons"
|
path: "addons"
|
||||||
|
|
||||||
- name: Setup HEMTT
|
- name: Setup HEMTT
|
||||||
uses: arma-actions/hemtt@v1
|
uses: arma-actions/hemtt@v1
|
||||||
- name: Run HEMTT check
|
- name: Run HEMTT check
|
||||||
run: hemtt check --pedantic
|
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:
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
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
|
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.
|
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.
|
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.
|
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||||
|
|
||||||
|
|||||||
@ -18,10 +18,13 @@
|
|||||||
The project is entirely **open-source** and any contributions are welcome.
|
The project is entirely **open-source** and any contributions are welcome.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
- Feature
|
- Feature
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Forge Client is licensed under [APL-SA](./LICENSE.md).
|
Forge Client is licensed under [APL-SA](./LICENSE.md).
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_actor
|
# forge_client_actor
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,39 +1,37 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8" />
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Interaction Menu</title>
|
||||||
<title>Interaction Menu</title>
|
<!-- <link rel="stylesheet" href="style.css"> -->
|
||||||
<!-- <link rel="stylesheet" href="style.css"> -->
|
<!--
|
||||||
<!--
|
|
||||||
Dynamic Resource Loading
|
Dynamic Resource Loading
|
||||||
The following script loads CSS and JavaScript files dynamically using the A3API
|
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
|
This approach is used instead of static HTML imports to work with Arma 3's file system
|
||||||
-->
|
-->
|
||||||
<script>
|
<script>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
|
"forge\\forge_client\\addons\\actor\\ui\\_site\\style.css",
|
||||||
),
|
),
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
|
"forge\\forge_client\\addons\\actor\\ui\\_site\\script.js",
|
||||||
),
|
),
|
||||||
]).then(([css, js]) => {
|
]).then(([css, js]) => {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = css;
|
style.textContent = css;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.text = js;
|
script.text = js;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- <script src="script.js"></script> -->
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- <script src="script.js"></script> -->
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -13,11 +13,11 @@ function h(tag, props = {}, ...children) {
|
|||||||
|
|
||||||
if (props) {
|
if (props) {
|
||||||
Object.entries(props).forEach(([key, value]) => {
|
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);
|
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||||
} else if (key === 'className') {
|
} else if (key === "className") {
|
||||||
el.className = value;
|
el.className = value;
|
||||||
} else if (key === 'style' && typeof value === 'object') {
|
} else if (key === "style" && typeof value === "object") {
|
||||||
Object.assign(el.style, value);
|
Object.assign(el.style, value);
|
||||||
} else {
|
} else {
|
||||||
el.setAttribute(key, value);
|
el.setAttribute(key, value);
|
||||||
@ -25,13 +25,13 @@ function h(tag, props = {}, ...children) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
children.forEach(child => {
|
children.forEach((child) => {
|
||||||
if (typeof child === 'string' || typeof child === 'number') {
|
if (typeof child === "string" || typeof child === "number") {
|
||||||
el.appendChild(document.createTextNode(child));
|
el.appendChild(document.createTextNode(child));
|
||||||
} else if (child instanceof Node) {
|
} else if (child instanceof Node) {
|
||||||
el.appendChild(child);
|
el.appendChild(child);
|
||||||
} else if (Array.isArray(child)) {
|
} else if (Array.isArray(child)) {
|
||||||
child.forEach(c => {
|
child.forEach((c) => {
|
||||||
if (c instanceof Node) el.appendChild(c);
|
if (c instanceof Node) el.appendChild(c);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -52,7 +52,7 @@ function render(component, container) {
|
|||||||
|
|
||||||
function _render() {
|
function _render() {
|
||||||
if (_rootContainer && _rootComponent) {
|
if (_rootContainer && _rootComponent) {
|
||||||
_rootContainer.innerHTML = '';
|
_rootContainer.innerHTML = "";
|
||||||
_rootContainer.appendChild(_rootComponent());
|
_rootContainer.appendChild(_rootComponent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -211,7 +211,9 @@ function actorReducer(state = initialState, action) {
|
|||||||
if (definition) {
|
if (definition) {
|
||||||
newMenuItems.push(definition);
|
newMenuItems.push(definition);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No definition found for: ${type} - ${value}`);
|
console.warn(
|
||||||
|
`No definition found for: ${type} - ${value}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("Invalid action format:", actionItem);
|
console.warn("Invalid action format:", actionItem);
|
||||||
@ -232,7 +234,10 @@ function actorReducer(state = initialState, action) {
|
|||||||
|
|
||||||
case ActionTypes.ADD_ACTION:
|
case ActionTypes.ADD_ACTION:
|
||||||
const definition = state.actionDefinitions[action.payload];
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
menuItems: [...state.menuItems, definition],
|
menuItems: [...state.menuItems, definition],
|
||||||
@ -243,7 +248,9 @@ function actorReducer(state = initialState, action) {
|
|||||||
case ActionTypes.REMOVE_ACTION:
|
case ActionTypes.REMOVE_ACTION:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
menuItems: state.menuItems.filter((item) => item.id !== action.payload),
|
menuItems: state.menuItems.filter(
|
||||||
|
(item) => item.id !== action.payload,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
case ActionTypes.CLEAR_ACTIONS:
|
case ActionTypes.CLEAR_ACTIONS:
|
||||||
@ -299,7 +306,8 @@ const selectors = {
|
|||||||
getAvailableActions: (state) => state.availableActions,
|
getAvailableActions: (state) => state.availableActions,
|
||||||
getBaseMenuItems: (state) => state.baseMenuItems,
|
getBaseMenuItems: (state) => state.baseMenuItems,
|
||||||
getActionDefinitions: (state) => state.actionDefinitions,
|
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,
|
getMenuItemsCount: (state) => state.menuItems.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -312,9 +320,11 @@ let tooltipEl = null;
|
|||||||
|
|
||||||
function createTooltip() {
|
function createTooltip() {
|
||||||
if (!tooltipEl) {
|
if (!tooltipEl) {
|
||||||
tooltipEl = h('div', { className: 'radial-tooltip' },
|
tooltipEl = h(
|
||||||
h('div', { className: 'tooltip-title' }),
|
"div",
|
||||||
h('div', { className: 'tooltip-description' })
|
{ className: "radial-tooltip" },
|
||||||
|
h("div", { className: "tooltip-title" }),
|
||||||
|
h("div", { className: "tooltip-description" }),
|
||||||
);
|
);
|
||||||
document.body.appendChild(tooltipEl);
|
document.body.appendChild(tooltipEl);
|
||||||
}
|
}
|
||||||
@ -323,16 +333,17 @@ function createTooltip() {
|
|||||||
|
|
||||||
function showTooltip(item, x, y) {
|
function showTooltip(item, x, y) {
|
||||||
const tooltip = createTooltip();
|
const tooltip = createTooltip();
|
||||||
tooltip.querySelector('.tooltip-title').textContent = item.title;
|
tooltip.querySelector(".tooltip-title").textContent = item.title;
|
||||||
tooltip.querySelector('.tooltip-description').textContent = item.description;
|
tooltip.querySelector(".tooltip-description").textContent =
|
||||||
|
item.description;
|
||||||
tooltip.style.left = `${x + 15}px`;
|
tooltip.style.left = `${x + 15}px`;
|
||||||
tooltip.style.top = `${y + 10}px`;
|
tooltip.style.top = `${y + 10}px`;
|
||||||
tooltip.classList.add('visible');
|
tooltip.classList.add("visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideTooltip() {
|
function hideTooltip() {
|
||||||
if (tooltipEl) {
|
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 x = centerX + menuRadius * Math.cos(angle) - itemSize / 2;
|
||||||
const y = centerY + menuRadius * Math.sin(angle) - itemSize / 2;
|
const y = centerY + menuRadius * Math.sin(angle) - itemSize / 2;
|
||||||
|
|
||||||
const el = h('div', {
|
const el = h(
|
||||||
className: 'radial-item',
|
"div",
|
||||||
style: {
|
{
|
||||||
left: `${x}px`,
|
className: "radial-item",
|
||||||
top: `${y}px`
|
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
|
// Add tooltip events
|
||||||
el.addEventListener('mouseenter', (e) => showTooltip(item, e.clientX, e.clientY));
|
el.addEventListener("mouseenter", (e) =>
|
||||||
el.addEventListener('mousemove', (e) => {
|
showTooltip(item, e.clientX, e.clientY),
|
||||||
if (tooltipEl && tooltipEl.classList.contains('visible')) {
|
);
|
||||||
|
el.addEventListener("mousemove", (e) => {
|
||||||
|
if (tooltipEl && tooltipEl.classList.contains("visible")) {
|
||||||
tooltipEl.style.left = `${e.clientX + 15}px`;
|
tooltipEl.style.left = `${e.clientX + 15}px`;
|
||||||
tooltipEl.style.top = `${e.clientY + 10}px`;
|
tooltipEl.style.top = `${e.clientY + 10}px`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
el.addEventListener('mouseleave', hideTooltip);
|
el.addEventListener("mouseleave", hideTooltip);
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadialCenter({ onClose }) {
|
function RadialCenter({ onClose }) {
|
||||||
return h('div', {
|
return h(
|
||||||
className: 'radial-center',
|
"div",
|
||||||
onClick: onClose
|
{
|
||||||
},
|
className: "radial-center",
|
||||||
h('div', { className: 'center-label' }, 'Close')
|
onClick: onClose,
|
||||||
|
},
|
||||||
|
h("div", { className: "center-label" }, "Close"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -393,7 +410,7 @@ function RadialMenu() {
|
|||||||
event: item.action,
|
event: item.action,
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
A3API.SendAlert(JSON.stringify(alert));
|
A3API.SendAlert(JSON.stringify(alert));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -404,27 +421,31 @@ function RadialMenu() {
|
|||||||
event: "actor::close::menu",
|
event: "actor::close::menu",
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
A3API.SendAlert(JSON.stringify(alert));
|
A3API.SendAlert(JSON.stringify(alert));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (menuItems.length === 0) {
|
if (menuItems.length === 0) {
|
||||||
return h('div', { className: 'empty-state' },
|
return h(
|
||||||
h('p', null, 'No actions available')
|
"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 }),
|
RadialCenter({ onClose: handleClose }),
|
||||||
menuItems.map((item, index) =>
|
menuItems.map((item, index) =>
|
||||||
RadialItem({
|
RadialItem({
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
total: menuItems.length,
|
total: menuItems.length,
|
||||||
onClick: handleItemClick
|
onClick: handleItemClick,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -460,14 +481,14 @@ function initializeMenu() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.getElementById('app');
|
const root = document.getElementById("app");
|
||||||
if (root) {
|
if (root) {
|
||||||
render(App, root);
|
render(App, root);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
console.log("Interaction menu initialized successfully");
|
console.log("Interaction menu initialized successfully");
|
||||||
|
|
||||||
// Request initial data from A3API
|
// Request initial data from A3API
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
const alert = {
|
const alert = {
|
||||||
event: "actor::get::actions",
|
event: "actor::get::actions",
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
--border: #e2e8f0;
|
--border: #e2e8f0;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
--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;
|
--menu-radius: 160px;
|
||||||
--item-size: 80px;
|
--item-size: 80px;
|
||||||
}
|
}
|
||||||
@ -22,7 +23,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_bank
|
# forge_client_bank
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -14,7 +14,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@ -182,7 +186,7 @@ button {
|
|||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&+& {
|
& + & {
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,45 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8" />
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>ATM</title>
|
||||||
<title>ATM</title>
|
<!-- <link rel="stylesheet" href="atm.css"> -->
|
||||||
<!-- <link rel="stylesheet" href="atm.css"> -->
|
<!--
|
||||||
<!--
|
|
||||||
Dynamic Resource Loading
|
Dynamic Resource Loading
|
||||||
The following script loads CSS and JavaScript files dynamically using the A3API
|
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
|
This approach is used instead of static HTML imports to work with Arma 3's file system
|
||||||
-->
|
-->
|
||||||
<script>
|
<script>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
A3API.RequestFile("forge\\forge_client\\addons\\bank\\ui\\_site\\atm.css"),
|
A3API.RequestFile(
|
||||||
A3API.RequestFile("forge\\forge_client\\addons\\bank\\ui\\_site\\store.js"),
|
"forge\\forge_client\\addons\\bank\\ui\\_site\\atm.css",
|
||||||
A3API.RequestFile("forge\\forge_client\\addons\\bank\\ui\\_site\\atm.js"),
|
),
|
||||||
]).then(([css, storeJs, atmJs]) => {
|
A3API.RequestFile(
|
||||||
const style = document.createElement("style");
|
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
|
||||||
style.textContent = css;
|
),
|
||||||
document.head.appendChild(style);
|
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");
|
const store = document.createElement("script");
|
||||||
store.text = storeJs;
|
store.text = storeJs;
|
||||||
document.head.appendChild(store);
|
document.head.appendChild(store);
|
||||||
|
|
||||||
const atm = document.createElement("script");
|
const atm = document.createElement("script");
|
||||||
atm.text = atmJs;
|
atm.text = atmJs;
|
||||||
document.head.appendChild(atm);
|
document.head.appendChild(atm);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- <script src="store.js"></script> -->
|
|
||||||
<!-- <script src="atm.js"></script> -->
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- <script src="store.js"></script> -->
|
||||||
|
<!-- <script src="atm.js"></script> -->
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -10,24 +10,24 @@ function h(tag, props = {}, ...children) {
|
|||||||
const el = document.createElement(tag);
|
const el = document.createElement(tag);
|
||||||
if (props) {
|
if (props) {
|
||||||
Object.entries(props).forEach(([key, value]) => {
|
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);
|
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||||
} else if (key === 'className') {
|
} else if (key === "className") {
|
||||||
el.className = value;
|
el.className = value;
|
||||||
} else if (key === 'style' && typeof value === 'object') {
|
} else if (key === "style" && typeof value === "object") {
|
||||||
Object.assign(el.style, value);
|
Object.assign(el.style, value);
|
||||||
} else {
|
} else {
|
||||||
el.setAttribute(key, value);
|
el.setAttribute(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
children.forEach(child => {
|
children.forEach((child) => {
|
||||||
if (typeof child === 'string' || typeof child === 'number') {
|
if (typeof child === "string" || typeof child === "number") {
|
||||||
el.appendChild(document.createTextNode(child));
|
el.appendChild(document.createTextNode(child));
|
||||||
} else if (child instanceof Node) {
|
} else if (child instanceof Node) {
|
||||||
el.appendChild(child);
|
el.appendChild(child);
|
||||||
} else if (Array.isArray(child)) {
|
} else if (Array.isArray(child)) {
|
||||||
child.forEach(c => {
|
child.forEach((c) => {
|
||||||
if (c instanceof Node) el.appendChild(c);
|
if (c instanceof Node) el.appendChild(c);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -46,7 +46,7 @@ function render(component, container) {
|
|||||||
|
|
||||||
function _render() {
|
function _render() {
|
||||||
if (_rootContainer && _rootComponent) {
|
if (_rootContainer && _rootComponent) {
|
||||||
_rootContainer.innerHTML = '';
|
_rootContainer.innerHTML = "";
|
||||||
_rootContainer.appendChild(_rootComponent());
|
_rootContainer.appendChild(_rootComponent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ const createSignal = (initialValue) => {
|
|||||||
let _val = initialValue;
|
let _val = initialValue;
|
||||||
const getValue = () => _val;
|
const getValue = () => _val;
|
||||||
const setValue = (newValue) => {
|
const setValue = (newValue) => {
|
||||||
_val = typeof newValue === 'function' ? newValue(_val) : newValue;
|
_val = typeof newValue === "function" ? newValue(_val) : newValue;
|
||||||
_render();
|
_render();
|
||||||
};
|
};
|
||||||
return [getValue, setValue];
|
return [getValue, setValue];
|
||||||
@ -65,19 +65,21 @@ const createSignal = (initialValue) => {
|
|||||||
// #region STATE
|
// #region STATE
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
const [getView, setView] = createSignal('pin'); // 'pin', 'menu', 'withdraw', 'custom_withdraw', 'balance'
|
const [getView, setView] = createSignal("pin"); // 'pin', 'menu', 'withdraw', 'custom_withdraw', 'balance'
|
||||||
const [getPin, setPin] = createSignal('');
|
const [getPin, setPin] = createSignal("");
|
||||||
const [getCustomAmount, setCustomAmount] = createSignal('');
|
const [getCustomAmount, setCustomAmount] = createSignal("");
|
||||||
const [getMessage, setMessage] = createSignal('');
|
const [getMessage, setMessage] = createSignal("");
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// #region UI COMPONENTS
|
// #region UI COMPONENTS
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
return h('div', { className: 'header', style: { marginBottom: '2rem' } },
|
return h(
|
||||||
h('h1', null, 'ATM TERMINAL'),
|
"div",
|
||||||
h('p', null, 'Global Financial Network')
|
{ 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) => {
|
const handleNumClick = (num) => {
|
||||||
if (currentPin.length < 4) {
|
if (currentPin.length < 4) {
|
||||||
setPin(prev => prev + num);
|
setPin((prev) => prev + num);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => setPin('');
|
const handleClear = () => setPin("");
|
||||||
|
|
||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
if (currentPin.length === 4) {
|
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) {
|
if (currentPin === state.pin) {
|
||||||
setView('menu');
|
setView("menu");
|
||||||
} else {
|
} else {
|
||||||
setMessage('Incorrect PIN');
|
setMessage("Incorrect PIN");
|
||||||
setPin('');
|
setPin("");
|
||||||
setTimeout(() => setMessage(''), 2000);
|
setTimeout(() => setMessage(""), 2000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMessage('Invalid PIN Length');
|
setMessage("Invalid PIN Length");
|
||||||
setTimeout(() => setMessage(''), 2000);
|
setTimeout(() => setMessage(""), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return h('div', { className: 'card', style: { padding: '3rem 2rem' } },
|
return h(
|
||||||
h('h2', null, 'Enter Security PIN'),
|
"div",
|
||||||
h('div', { className: 'pin-display' },
|
{ className: "card", style: { padding: "3rem 2rem" } },
|
||||||
currentPin.replace(/./g, String.fromCharCode(8226)) || '----'
|
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(
|
||||||
h('div', { className: 'numpad' },
|
"p",
|
||||||
['1', '2', '3', '4', '5', '6', '7', '8', '9'].map(num =>
|
{
|
||||||
h('button', { onClick: () => handleNumClick(num) }, num)
|
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(
|
||||||
h('button', { onClick: () => handleNumClick('0') }, '0'),
|
"button",
|
||||||
h('button', { style: { background: '#10b981', color: 'white' }, onClick: handleEnter }, String.fromCharCode(8629))
|
{
|
||||||
)
|
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() {
|
function MenuView() {
|
||||||
return h('div', { className: 'kiosk-content' },
|
return h(
|
||||||
h('h2', { style: { textAlign: 'center', marginBottom: '1rem' } }, 'Select Transaction'),
|
"div",
|
||||||
h('div', { className: 'kiosk-menu-stack' },
|
{ className: "kiosk-content" },
|
||||||
h('button', { className: 'kiosk-btn', onClick: () => setView('withdraw') },
|
h(
|
||||||
'Withdraw Cash'
|
"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') },
|
h(
|
||||||
'Check Balance'
|
"button",
|
||||||
|
{ className: "kiosk-btn", onClick: () => setView("balance") },
|
||||||
|
"Check Balance",
|
||||||
),
|
),
|
||||||
h('button', {
|
h(
|
||||||
className: 'kiosk-btn',
|
"button",
|
||||||
style: { background: 'var(--bg-surface)', color: 'var(--text-main)', border: '1px solid var(--border)' },
|
{
|
||||||
onClick: () => {
|
className: "kiosk-btn",
|
||||||
setPin('');
|
style: {
|
||||||
setView('pin');
|
background: "var(--bg-surface)",
|
||||||
sendEvent('atm::close', {});
|
color: "var(--text-main)",
|
||||||
}
|
border: "1px solid var(--border)",
|
||||||
}, 'Cancel Transaction')
|
},
|
||||||
)
|
onClick: () => {
|
||||||
|
setPin("");
|
||||||
|
setView("pin");
|
||||||
|
sendEvent("atm::close", {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Cancel Transaction",
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WithdrawView() {
|
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 bankBalance = state.accounts?.bank || 0;
|
||||||
|
|
||||||
const handleWithdraw = (amount) => {
|
const handleWithdraw = (amount) => {
|
||||||
if (bankBalance >= amount) {
|
if (bankBalance >= amount) {
|
||||||
if (typeof store !== 'undefined') {
|
if (typeof store !== "undefined") {
|
||||||
store.dispatch(withdraw(amount));
|
store.dispatch(withdraw(amount));
|
||||||
}
|
}
|
||||||
sendEvent('atm::withdraw', { amount });
|
sendEvent("atm::withdraw", { amount });
|
||||||
setMessage(`Please take your cash: $${amount.toLocaleString()}`);
|
setMessage(`Please take your cash: $${amount.toLocaleString()}`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setMessage('');
|
setMessage("");
|
||||||
setView('menu');
|
setView("menu");
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
setMessage('Insufficient Funds');
|
setMessage("Insufficient Funds");
|
||||||
setTimeout(() => setMessage(''), 2000);
|
setTimeout(() => setMessage(""), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (getMessage()) {
|
if (getMessage()) {
|
||||||
return h('div', { className: 'card', style: { padding: '4rem', textAlign: 'center' } },
|
return h(
|
||||||
h('h2', { style: { color: 'var(--primary)' } }, getMessage())
|
"div",
|
||||||
|
{
|
||||||
|
className: "card",
|
||||||
|
style: { padding: "4rem", textAlign: "center" },
|
||||||
|
},
|
||||||
|
h("h2", { style: { color: "var(--primary)" } }, getMessage()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return h('div', { className: 'kiosk-content' },
|
return h(
|
||||||
h('h2', { style: { textAlign: 'center', marginBottom: '1rem' } }, 'Select Amount'),
|
"div",
|
||||||
h('div', { className: 'kiosk-grid' },
|
{ className: "kiosk-content" },
|
||||||
h('button', { className: 'kiosk-btn', onClick: () => handleWithdraw(20) }, '$20'),
|
h(
|
||||||
h('button', { className: 'kiosk-btn', onClick: () => handleWithdraw(50) }, '$50'),
|
"h2",
|
||||||
h('button', { className: 'kiosk-btn', onClick: () => handleWithdraw(100) }, '$100'),
|
{ style: { textAlign: "center", marginBottom: "1rem" } },
|
||||||
h('button', {
|
"Select Amount",
|
||||||
className: 'kiosk-btn',
|
),
|
||||||
onClick: () => {
|
h(
|
||||||
setCustomAmount('');
|
"div",
|
||||||
setView('custom_withdraw');
|
{ className: "kiosk-grid" },
|
||||||
}
|
h(
|
||||||
}, 'Other Amount'),
|
"button",
|
||||||
h('button', { className: 'kiosk-btn', style: { gridColumn: 'span 2', background: 'var(--text-muted)' }, onClick: () => setView('menu') }, 'Cancel')
|
{ 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() {
|
function CustomWithdrawView() {
|
||||||
const currentAmount = getCustomAmount();
|
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 bankBalance = state.accounts?.bank || 0;
|
||||||
|
|
||||||
const handleNumClick = (num) => {
|
const handleNumClick = (num) => {
|
||||||
if (currentAmount.length < 5) {
|
if (currentAmount.length < 5) {
|
||||||
setCustomAmount(prev => prev + num);
|
setCustomAmount((prev) => prev + num);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => setCustomAmount('');
|
const handleClear = () => setCustomAmount("");
|
||||||
|
|
||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
const amount = parseInt(currentAmount, 10);
|
const amount = parseInt(currentAmount, 10);
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
if (bankBalance >= amount) {
|
if (bankBalance >= amount) {
|
||||||
if (typeof store !== 'undefined') {
|
if (typeof store !== "undefined") {
|
||||||
store.dispatch(withdraw(amount));
|
store.dispatch(withdraw(amount));
|
||||||
}
|
}
|
||||||
sendEvent('atm::withdraw', { amount });
|
sendEvent("atm::withdraw", { amount });
|
||||||
setMessage(`Please take your cash: $${amount.toLocaleString()}`);
|
setMessage(
|
||||||
|
`Please take your cash: $${amount.toLocaleString()}`,
|
||||||
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setMessage('');
|
setMessage("");
|
||||||
setView('menu');
|
setView("menu");
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
setMessage('Insufficient Funds');
|
setMessage("Insufficient Funds");
|
||||||
setTimeout(() => setMessage(''), 2000);
|
setTimeout(() => setMessage(""), 2000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMessage('Invalid Amount');
|
setMessage("Invalid Amount");
|
||||||
setTimeout(() => setMessage(''), 2000);
|
setTimeout(() => setMessage(""), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (getMessage()) {
|
if (getMessage()) {
|
||||||
return h('div', { className: 'card', style: { padding: '4rem', textAlign: 'center' } },
|
return h(
|
||||||
h('h2', { style: { color: 'var(--primary)' } }, getMessage())
|
"div",
|
||||||
|
{
|
||||||
|
className: "card",
|
||||||
|
style: { padding: "4rem", textAlign: "center" },
|
||||||
|
},
|
||||||
|
h("h2", { style: { color: "var(--primary)" } }, getMessage()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return h('div', { className: 'card', style: { padding: '3rem 2rem' } },
|
return h(
|
||||||
h('h2', null, 'Enter Amount'),
|
"div",
|
||||||
h('div', { className: 'pin-display' },
|
{ className: "card", style: { padding: "3rem 2rem" } },
|
||||||
currentAmount ? `$${currentAmount}` : '$0'
|
h("h2", null, "Enter Amount"),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ className: "pin-display" },
|
||||||
|
currentAmount ? `$${currentAmount}` : "$0",
|
||||||
),
|
),
|
||||||
h('div', { className: 'numpad' },
|
h(
|
||||||
['1', '2', '3', '4', '5', '6', '7', '8', '9'].map(num =>
|
"div",
|
||||||
h('button', { onClick: () => handleNumClick(num) }, num)
|
{ 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', {
|
h(
|
||||||
style: { width: '100%', marginTop: '2rem', padding: '1rem', background: 'var(--text-muted)' },
|
"button",
|
||||||
onClick: () => setView('withdraw')
|
{
|
||||||
}, 'Cancel')
|
style: {
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "2rem",
|
||||||
|
padding: "1rem",
|
||||||
|
background: "var(--text-muted)",
|
||||||
|
},
|
||||||
|
onClick: () => setView("withdraw"),
|
||||||
|
},
|
||||||
|
"Cancel",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BalanceView() {
|
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;
|
const bankBalance = state.accounts?.bank || 0;
|
||||||
|
|
||||||
return h('div', { className: 'card', style: { textAlign: 'center', padding: '3rem' } },
|
return h(
|
||||||
h('h2', { style: { color: 'var(--text-muted)' } }, 'Available Balance'),
|
"div",
|
||||||
h('div', { style: { fontSize: '4rem', fontWeight: '800', margin: '2rem 0', color: 'var(--primary-hover)' } },
|
{ className: "card", style: { textAlign: "center", padding: "3rem" } },
|
||||||
'$' + bankBalance.toLocaleString()
|
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();
|
const view = getView();
|
||||||
|
|
||||||
let mainContent;
|
let mainContent;
|
||||||
if (view === 'pin') {
|
if (view === "pin") {
|
||||||
mainContent = PinView();
|
mainContent = PinView();
|
||||||
} else if (view === 'menu') {
|
} else if (view === "menu") {
|
||||||
mainContent = MenuView();
|
mainContent = MenuView();
|
||||||
} else if (view === 'withdraw') {
|
} else if (view === "withdraw") {
|
||||||
mainContent = WithdrawView();
|
mainContent = WithdrawView();
|
||||||
} else if (view === 'custom_withdraw') {
|
} else if (view === "custom_withdraw") {
|
||||||
mainContent = CustomWithdrawView();
|
mainContent = CustomWithdrawView();
|
||||||
} else if (view === 'balance') {
|
} else if (view === "balance") {
|
||||||
mainContent = BalanceView();
|
mainContent = BalanceView();
|
||||||
}
|
}
|
||||||
|
|
||||||
return h('main', null,
|
return h(
|
||||||
h('div', { className: 'container' },
|
"main",
|
||||||
Header(),
|
null,
|
||||||
mainContent
|
h("div", { className: "container" }, Header(), mainContent),
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,10 +455,10 @@ function App() {
|
|||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
function sendEvent(event, data) {
|
function sendEvent(event, data) {
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
A3API.SendAlert(JSON.stringify({ event, data }));
|
A3API.SendAlert(JSON.stringify({ event, data }));
|
||||||
} else {
|
} else {
|
||||||
console.log('Event:', event, 'Data:', data);
|
console.log("Event:", event, "Data:", data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,20 +471,20 @@ let initialized = false;
|
|||||||
function initATM() {
|
function initATM() {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
|
|
||||||
const root = document.getElementById('app');
|
const root = document.getElementById("app");
|
||||||
if (root) {
|
if (root) {
|
||||||
if (typeof store !== 'undefined') {
|
if (typeof store !== "undefined") {
|
||||||
store.subscribe(() => _render());
|
store.subscribe(() => _render());
|
||||||
}
|
}
|
||||||
|
|
||||||
render(App, root);
|
render(App, root);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
console.log('[ATM] Interface initialized');
|
console.log("[ATM] Interface initialized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', initATM);
|
document.addEventListener("DOMContentLoaded", initATM);
|
||||||
} else {
|
} else {
|
||||||
initATM();
|
initATM();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
@ -198,7 +202,7 @@ main {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
font-family: "Consolas", "Monaco", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@ -244,7 +248,7 @@ button {
|
|||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&+& {
|
& + & {
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,45 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8" />
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>FDIC - Global Financial Network</title>
|
||||||
<title>FDIC - Global Financial Network</title>
|
<!-- <link rel="stylesheet" href="bank.css"> -->
|
||||||
<!-- <link rel="stylesheet" href="bank.css"> -->
|
<!--
|
||||||
<!--
|
|
||||||
Dynamic Resource Loading
|
Dynamic Resource Loading
|
||||||
The following script loads CSS and JavaScript files dynamically using the A3API
|
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
|
This approach is used instead of static HTML imports to work with Arma 3's file system
|
||||||
-->
|
-->
|
||||||
<script>
|
<script>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.css",
|
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.css",
|
||||||
),
|
),
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
|
"forge\\forge_client\\addons\\bank\\ui\\_site\\store.js",
|
||||||
),
|
),
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.js",
|
"forge\\forge_client\\addons\\bank\\ui\\_site\\bank.js",
|
||||||
),
|
),
|
||||||
]).then(([css, storeJs, bankJs]) => {
|
]).then(([css, storeJs, bankJs]) => {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = css;
|
style.textContent = css;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const storeScript = document.createElement("script");
|
const storeScript = document.createElement("script");
|
||||||
storeScript.text = storeJs;
|
storeScript.text = storeJs;
|
||||||
document.head.appendChild(storeScript);
|
document.head.appendChild(storeScript);
|
||||||
|
|
||||||
const bankScript = document.createElement("script");
|
const bankScript = document.createElement("script");
|
||||||
bankScript.text = bankJs;
|
bankScript.text = bankJs;
|
||||||
document.head.appendChild(bankScript);
|
document.head.appendChild(bankScript);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<!-- <script src="store.js"></script> -->
|
|
||||||
<!-- <script src="bank.js"></script> -->
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- <script src="store.js"></script> -->
|
||||||
|
<!-- <script src="bank.js"></script> -->
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -10,26 +10,31 @@ function h(tag, props = {}, ...children) {
|
|||||||
const el = document.createElement(tag);
|
const el = document.createElement(tag);
|
||||||
if (props) {
|
if (props) {
|
||||||
Object.entries(props).forEach(([key, value]) => {
|
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);
|
el.addEventListener(key.substring(2).toLowerCase(), value);
|
||||||
} else if (key === 'className') {
|
} else if (key === "className") {
|
||||||
el.className = value;
|
el.className = value;
|
||||||
} else if (key === 'style' && typeof value === 'object') {
|
} else if (key === "style" && typeof value === "object") {
|
||||||
Object.assign(el.style, value);
|
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;
|
if (value) el[key] = true;
|
||||||
} else {
|
} else {
|
||||||
el.setAttribute(key, value);
|
el.setAttribute(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
children.forEach(child => {
|
children.forEach((child) => {
|
||||||
if (typeof child === 'string' || typeof child === 'number') {
|
if (typeof child === "string" || typeof child === "number") {
|
||||||
el.appendChild(document.createTextNode(child));
|
el.appendChild(document.createTextNode(child));
|
||||||
} else if (child instanceof Node) {
|
} else if (child instanceof Node) {
|
||||||
el.appendChild(child);
|
el.appendChild(child);
|
||||||
} else if (Array.isArray(child)) {
|
} else if (Array.isArray(child)) {
|
||||||
child.forEach(c => {
|
child.forEach((c) => {
|
||||||
if (c instanceof Node) el.appendChild(c);
|
if (c instanceof Node) el.appendChild(c);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -48,7 +53,7 @@ function render(component, container) {
|
|||||||
|
|
||||||
function _render() {
|
function _render() {
|
||||||
if (_rootContainer && _rootComponent) {
|
if (_rootContainer && _rootComponent) {
|
||||||
_rootContainer.innerHTML = '';
|
_rootContainer.innerHTML = "";
|
||||||
_rootContainer.appendChild(_rootComponent());
|
_rootContainer.appendChild(_rootComponent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,52 +64,92 @@ function _render() {
|
|||||||
|
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const uid = state.uid || 'Unknown';
|
const uid = state.uid || "Unknown";
|
||||||
|
|
||||||
return h('nav', { className: 'navbar' },
|
return h(
|
||||||
h('div', { className: 'navbar-inner' },
|
"nav",
|
||||||
h('div', { className: 'navbar-brand' },
|
{ className: "navbar" },
|
||||||
h('span', { className: 'navbar-title' }, 'FDIC - Global Financial Network')
|
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(
|
||||||
h('div', { className: 'profile-info' },
|
"div",
|
||||||
h('span', { className: 'profile-label' }, 'Account'),
|
{ className: "navbar-profile" },
|
||||||
h('span', { className: 'profile-id' }, uid)
|
h(
|
||||||
)
|
"div",
|
||||||
)
|
{ className: "profile-info" },
|
||||||
)
|
h("span", { className: "profile-label" }, "Account"),
|
||||||
|
h("span", { className: "profile-id" }, uid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WindowTitleBar() {
|
function WindowTitleBar() {
|
||||||
return h('div', { className: 'window-titlebar' },
|
return h(
|
||||||
h('div', { className: 'window-titlebar-brand' },
|
"div",
|
||||||
h('span', { className: 'window-titlebar-kicker' }, 'FDIC Workspace'),
|
{ className: "window-titlebar" },
|
||||||
h('span', { className: 'window-titlebar-title' }, 'Global Financial Network')
|
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 state = store.getState();
|
||||||
const transactions = state.transactions || [];
|
const transactions = state.transactions || [];
|
||||||
|
|
||||||
return h('div', { className: 'card' },
|
return h(
|
||||||
h('h3', { style: { textAlign: 'left', borderBottom: '1px solid var(--border)', paddingBottom: '1rem', marginBottom: '1rem' } }, 'Recent Transactions'),
|
"div",
|
||||||
|
{ className: "card" },
|
||||||
|
h(
|
||||||
|
"h3",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
textAlign: "left",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
paddingBottom: "1rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Recent Transactions",
|
||||||
|
),
|
||||||
transactions.length === 0
|
transactions.length === 0
|
||||||
? h('p', { style: { color: 'var(--text-muted)' } }, 'No transactions yet')
|
? h(
|
||||||
: h('ul', { style: { listStyle: 'none', padding: 0, margin: 0 } },
|
"p",
|
||||||
transactions.slice(0, 10).map(tx => {
|
{ style: { color: "var(--text-muted)" } },
|
||||||
const isCredit = tx.type === 'Deposit';
|
"No transactions yet",
|
||||||
return h('li', {
|
)
|
||||||
style: {
|
: h(
|
||||||
display: 'flex',
|
"ul",
|
||||||
justifyContent: 'space-between',
|
{ style: { listStyle: "none", padding: 0, margin: 0 } },
|
||||||
padding: '0.75rem 0',
|
transactions.slice(0, 10).map((tx) => {
|
||||||
borderBottom: '1px solid var(--bg-surface-hover)'
|
const isCredit = tx.type === "Deposit";
|
||||||
}
|
return h(
|
||||||
},
|
"li",
|
||||||
h('div', { style: { textAlign: 'left' } },
|
{
|
||||||
h('div', { style: { fontWeight: '500' } }, tx.type),
|
style: {
|
||||||
h('div', { style: { fontSize: '0.85rem', color: 'var(--text-muted)' } }, tx.date)
|
display: "flex",
|
||||||
),
|
justifyContent: "space-between",
|
||||||
h('div', {
|
padding: "0.75rem 0",
|
||||||
style: {
|
borderBottom:
|
||||||
fontWeight: '700',
|
"1px solid var(--bg-surface-hover)",
|
||||||
color: isCredit ? '#10b981' : '#ef4444'
|
},
|
||||||
}
|
},
|
||||||
}, (isCredit ? '+' : '-') + '$' + Math.abs(tx.amount).toLocaleString())
|
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 cashBalance = state.accounts.cash;
|
||||||
|
|
||||||
const getAmount = () => {
|
const getAmount = () => {
|
||||||
const input = document.getElementById('deposit-withdraw-amount');
|
const input = document.getElementById("deposit-withdraw-amount");
|
||||||
return parseFloat(input?.value) || 0;
|
return parseFloat(input?.value) || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
const input = document.getElementById('deposit-withdraw-amount');
|
const input = document.getElementById("deposit-withdraw-amount");
|
||||||
if (input) input.value = '';
|
if (input) input.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeposit = () => {
|
const handleDeposit = () => {
|
||||||
const amount = getAmount();
|
const amount = getAmount();
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
console.log('Please enter a valid amount');
|
console.log("Please enter a valid amount");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (amount > cashBalance) {
|
if (amount > cashBalance) {
|
||||||
console.log('Insufficient cash');
|
console.log("Insufficient cash");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendEvent('bank::deposit', { amount });
|
sendEvent("bank::deposit", { amount });
|
||||||
store.dispatch(deposit(amount));
|
store.dispatch(deposit(amount));
|
||||||
clearInput();
|
clearInput();
|
||||||
};
|
};
|
||||||
@ -176,37 +264,70 @@ function DepositWithdrawForm() {
|
|||||||
const handleWithdraw = () => {
|
const handleWithdraw = () => {
|
||||||
const amount = getAmount();
|
const amount = getAmount();
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
console.log('Please enter a valid amount');
|
console.log("Please enter a valid amount");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (amount > bankBalance) {
|
if (amount > bankBalance) {
|
||||||
console.log('Insufficient funds');
|
console.log("Insufficient funds");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendEvent('bank::withdraw', { amount });
|
sendEvent("bank::withdraw", { amount });
|
||||||
store.dispatch(withdraw(amount));
|
store.dispatch(withdraw(amount));
|
||||||
clearInput();
|
clearInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
return h('div', { className: 'card' },
|
return h(
|
||||||
h('h2', null, 'Deposit / Withdraw'),
|
"div",
|
||||||
h('div', { className: 'balance-info' },
|
{ className: "card" },
|
||||||
h('div', { className: 'balance-info-item' },
|
h("h2", null, "Deposit / Withdraw"),
|
||||||
h('span', { className: 'balance-info-label' }, 'Cash'),
|
h(
|
||||||
h('span', { className: 'balance-info-value cash' }, '$' + cashBalance.toLocaleString())
|
"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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
const amount = parseFloat(formData.get('amount'));
|
const amount = parseFloat(formData.get("amount"));
|
||||||
const playerId = formData.get('playerId');
|
const playerId = formData.get("playerId");
|
||||||
|
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
console.log('Please enter a valid amount');
|
console.log("Please enter a valid amount");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentState = store.getState();
|
const currentState = store.getState();
|
||||||
|
|
||||||
if (!playerId) {
|
if (!playerId) {
|
||||||
console.log('Please select a recipient');
|
console.log("Please select a recipient");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount > currentState.accounts.bank) {
|
if (amount > currentState.accounts.bank) {
|
||||||
console.log('Insufficient funds');
|
console.log("Insufficient funds");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent('bank::transfer', { from: 'bank', amount, target: playerId });
|
sendEvent("bank::transfer", { from: "bank", amount, target: playerId });
|
||||||
store.dispatch(transfer('bank', amount, 'player'));
|
store.dispatch(transfer("bank", amount, "player"));
|
||||||
e.target.reset();
|
e.target.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build player options
|
// Build player options
|
||||||
const playerOptions = [h('option', { value: '', disabled: true, selected: true }, 'Select player...')];
|
const playerOptions = [
|
||||||
Object.keys(players).forEach(uid => {
|
h(
|
||||||
|
"option",
|
||||||
|
{ value: "", disabled: true, selected: true },
|
||||||
|
"Select player...",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
Object.keys(players).forEach((uid) => {
|
||||||
if (uid !== currentUid && players[uid]?.name) {
|
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' },
|
return h(
|
||||||
h('h2', null, 'Wire Transfer'),
|
"div",
|
||||||
h('form', { onSubmit: handleSubmit },
|
{ className: "card" },
|
||||||
h('div', null,
|
h("h2", null, "Wire Transfer"),
|
||||||
h('label', null, 'Recipient'),
|
h(
|
||||||
h('select', { name: 'playerId' }, playerOptions)
|
"form",
|
||||||
|
{ onSubmit: handleSubmit },
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
null,
|
||||||
|
h("label", null, "Recipient"),
|
||||||
|
h("select", { name: "playerId" }, playerOptions),
|
||||||
),
|
),
|
||||||
h('div', null,
|
h(
|
||||||
h('label', null, 'Amount'),
|
"div",
|
||||||
h('input', { name: 'amount', type: 'number', placeholder: '0.00' })
|
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 bankBalance = state.accounts.bank;
|
||||||
const earnings = state.accounts.earnings;
|
const earnings = state.accounts.earnings;
|
||||||
|
|
||||||
return h('div', { className: 'content' },
|
return h(
|
||||||
h('div', { className: 'card', style: { gridColumn: 'span 2' } },
|
"div",
|
||||||
h('h2', { style: { fontSize: '1.2rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' } }, 'Account Balance'),
|
{ className: "content" },
|
||||||
h('div', { style: { fontSize: '2.8rem', fontWeight: '800', color: 'var(--primary-hover)', margin: '1rem 0' } },
|
h(
|
||||||
'$' + bankBalance.toLocaleString()
|
"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' } },
|
h(
|
||||||
'Pending: ',
|
"div",
|
||||||
h('span', { style: { color: '#fbbf24', fontWeight: 'bold' } }, '$' + earnings.toLocaleString())
|
{
|
||||||
|
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(),
|
DepositWithdrawForm(),
|
||||||
TransferForm(),
|
TransferForm(),
|
||||||
h('div', { style: { gridColumn: 'span 2' } }, TransactionHistory())
|
h("div", { style: { gridColumn: "span 2" } }, TransactionHistory()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
return h('div', { className: 'footer' },
|
return h(
|
||||||
h('div', { className: 'wrapper' },
|
"div",
|
||||||
h('div', null,
|
{ className: "footer" },
|
||||||
h('h3', null, 'Secure Banking'),
|
h(
|
||||||
h('ul', { style: { listStyleType: 'none', padding: 0 } },
|
"div",
|
||||||
h('li', null, 'FDIC Insured'),
|
{ className: "wrapper" },
|
||||||
h('li', null, 'Fraud Protection'),
|
h(
|
||||||
h('li', null, '24/7 Support'),
|
"div",
|
||||||
h('li', null, 'API Access')
|
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(
|
||||||
h('h3', null, 'Notices'),
|
"div",
|
||||||
h('ul', { style: { listStyleType: 'none', padding: 0 } },
|
null,
|
||||||
h('li', null, 'Terms of Service'),
|
h("h3", null, "Notices"),
|
||||||
h('li', null, 'Privacy Policy'),
|
h(
|
||||||
h('li', null, 'Interest Rates'),
|
"ul",
|
||||||
h('li', null, 'Report Fraud')
|
{ 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() {
|
function App() {
|
||||||
return h('div', { className: 'app-shell' },
|
return h(
|
||||||
|
"div",
|
||||||
|
{ className: "app-shell" },
|
||||||
WindowTitleBar(),
|
WindowTitleBar(),
|
||||||
h('main', null,
|
h(
|
||||||
|
"main",
|
||||||
|
null,
|
||||||
Navbar(),
|
Navbar(),
|
||||||
h('div', { className: 'container' },
|
h("div", { className: "container" }, BankDashboard()),
|
||||||
BankDashboard()
|
Footer(),
|
||||||
),
|
),
|
||||||
Footer()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,10 +540,10 @@ function App() {
|
|||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
function sendEvent(event, data) {
|
function sendEvent(event, data) {
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
A3API.SendAlert(JSON.stringify({ event, data }));
|
A3API.SendAlert(JSON.stringify({ event, data }));
|
||||||
} else {
|
} else {
|
||||||
console.log('Event:', event, 'Data:', data);
|
console.log("Event:", event, "Data:", data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,20 +556,20 @@ let initialized = false;
|
|||||||
function initBank() {
|
function initBank() {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
|
|
||||||
const root = document.getElementById('app');
|
const root = document.getElementById("app");
|
||||||
if (root) {
|
if (root) {
|
||||||
if (typeof store !== 'undefined') {
|
if (typeof store !== "undefined") {
|
||||||
store.subscribe(() => _render());
|
store.subscribe(() => _render());
|
||||||
}
|
}
|
||||||
|
|
||||||
render(App, root);
|
render(App, root);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
console.log('[Bank] Interface initialized');
|
console.log("[Bank] Interface initialized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', initBank);
|
document.addEventListener("DOMContentLoaded", initBank);
|
||||||
} else {
|
} else {
|
||||||
initBank();
|
initBank();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,13 +20,13 @@ function createStore(reducer) {
|
|||||||
|
|
||||||
const dispatch = (action) => {
|
const dispatch = (action) => {
|
||||||
state = reducer(state, action);
|
state = reducer(state, action);
|
||||||
listeners.forEach(listener => listener());
|
listeners.forEach((listener) => listener());
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscribe = (listener) => {
|
const subscribe = (listener) => {
|
||||||
listeners.push(listener);
|
listeners.push(listener);
|
||||||
return () => {
|
return () => {
|
||||||
listeners = listeners.filter(l => l !== listener);
|
listeners = listeners.filter((l) => l !== listener);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -41,27 +41,27 @@ function createStore(reducer) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
uid: '',
|
uid: "",
|
||||||
accounts: {
|
accounts: {
|
||||||
bank: 0,
|
bank: 0,
|
||||||
cash: 0,
|
cash: 0,
|
||||||
earnings: 0,
|
earnings: 0,
|
||||||
org: 0
|
org: 0,
|
||||||
},
|
},
|
||||||
pin: '1234',
|
pin: "1234",
|
||||||
transactions: []
|
transactions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACTION TYPES
|
// ACTION TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const DEPOSIT = 'DEPOSIT';
|
const DEPOSIT = "DEPOSIT";
|
||||||
const DEPOSIT_EARNINGS = 'DEPOSIT_EARNINGS';
|
const DEPOSIT_EARNINGS = "DEPOSIT_EARNINGS";
|
||||||
const WITHDRAW = 'WITHDRAW';
|
const WITHDRAW = "WITHDRAW";
|
||||||
const TRANSFER = 'TRANSFER';
|
const TRANSFER = "TRANSFER";
|
||||||
const UPDATE_ACCOUNTS = 'UPDATE_ACCOUNTS';
|
const UPDATE_ACCOUNTS = "UPDATE_ACCOUNTS";
|
||||||
const UPDATE_PIN = 'UPDATE_PIN';
|
const UPDATE_PIN = "UPDATE_PIN";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACTION CREATORS
|
// ACTION CREATORS
|
||||||
@ -69,34 +69,34 @@ const UPDATE_PIN = 'UPDATE_PIN';
|
|||||||
|
|
||||||
const deposit = (amount) => ({
|
const deposit = (amount) => ({
|
||||||
type: DEPOSIT,
|
type: DEPOSIT,
|
||||||
payload: amount
|
payload: amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const depositEarnings = (amount) => ({
|
const depositEarnings = (amount) => ({
|
||||||
type: DEPOSIT_EARNINGS,
|
type: DEPOSIT_EARNINGS,
|
||||||
payload: amount
|
payload: amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const withdraw = (amount) => ({
|
const withdraw = (amount) => ({
|
||||||
type: WITHDRAW,
|
type: WITHDRAW,
|
||||||
payload: amount
|
payload: amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const transfer = (from, amount, target) => ({
|
const transfer = (from, amount, target) => ({
|
||||||
type: TRANSFER,
|
type: TRANSFER,
|
||||||
from: from,
|
from: from,
|
||||||
payload: amount,
|
payload: amount,
|
||||||
target: target
|
target: target,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateAccounts = (accounts) => ({
|
const updateAccounts = (accounts) => ({
|
||||||
type: UPDATE_ACCOUNTS,
|
type: UPDATE_ACCOUNTS,
|
||||||
payload: accounts
|
payload: accounts,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePin = (pin) => ({
|
const updatePin = (pin) => ({
|
||||||
type: UPDATE_PIN,
|
type: UPDATE_PIN,
|
||||||
payload: pin
|
payload: pin,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -107,7 +107,7 @@ function appReducer(state = initialState, action) {
|
|||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case DEPOSIT:
|
case DEPOSIT:
|
||||||
if (state.accounts.cash < action.payload) {
|
if (state.accounts.cash < action.payload) {
|
||||||
console.warn('Insufficient cash!');
|
console.warn("Insufficient cash!");
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -115,21 +115,21 @@ function appReducer(state = initialState, action) {
|
|||||||
accounts: {
|
accounts: {
|
||||||
...state.accounts,
|
...state.accounts,
|
||||||
bank: state.accounts.bank + action.payload,
|
bank: state.accounts.bank + action.payload,
|
||||||
cash: state.accounts.cash - action.payload
|
cash: state.accounts.cash - action.payload,
|
||||||
},
|
},
|
||||||
transactions: [
|
transactions: [
|
||||||
...state.transactions,
|
...state.transactions,
|
||||||
{
|
{
|
||||||
type: 'Deposit',
|
type: "Deposit",
|
||||||
amount: action.payload,
|
amount: action.payload,
|
||||||
date: new Date().toLocaleString()
|
date: new Date().toLocaleString(),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
case DEPOSIT_EARNINGS:
|
case DEPOSIT_EARNINGS:
|
||||||
if (state.accounts.earnings < action.payload) {
|
if (state.accounts.earnings < action.payload) {
|
||||||
console.warn('Insufficient earnings!');
|
console.warn("Insufficient earnings!");
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -137,21 +137,21 @@ function appReducer(state = initialState, action) {
|
|||||||
accounts: {
|
accounts: {
|
||||||
...state.accounts,
|
...state.accounts,
|
||||||
bank: state.accounts.bank + action.payload,
|
bank: state.accounts.bank + action.payload,
|
||||||
earnings: state.accounts.earnings - action.payload
|
earnings: state.accounts.earnings - action.payload,
|
||||||
},
|
},
|
||||||
transactions: [
|
transactions: [
|
||||||
...state.transactions,
|
...state.transactions,
|
||||||
{
|
{
|
||||||
type: 'Deposit Earnings',
|
type: "Deposit Earnings",
|
||||||
amount: action.payload,
|
amount: action.payload,
|
||||||
date: new Date().toLocaleString()
|
date: new Date().toLocaleString(),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
case WITHDRAW:
|
case WITHDRAW:
|
||||||
if (state.accounts.bank < action.payload) {
|
if (state.accounts.bank < action.payload) {
|
||||||
console.warn('Insufficient funds!');
|
console.warn("Insufficient funds!");
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -159,22 +159,22 @@ function appReducer(state = initialState, action) {
|
|||||||
accounts: {
|
accounts: {
|
||||||
...state.accounts,
|
...state.accounts,
|
||||||
bank: state.accounts.bank - action.payload,
|
bank: state.accounts.bank - action.payload,
|
||||||
cash: state.accounts.cash + action.payload
|
cash: state.accounts.cash + action.payload,
|
||||||
},
|
},
|
||||||
transactions: [
|
transactions: [
|
||||||
...state.transactions,
|
...state.transactions,
|
||||||
{
|
{
|
||||||
type: 'Withdraw',
|
type: "Withdraw",
|
||||||
amount: action.payload,
|
amount: action.payload,
|
||||||
date: new Date().toLocaleString()
|
date: new Date().toLocaleString(),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
case TRANSFER:
|
case TRANSFER:
|
||||||
const fromAccount = action.from;
|
const fromAccount = action.from;
|
||||||
if (state.accounts[fromAccount] < action.payload) {
|
if (state.accounts[fromAccount] < action.payload) {
|
||||||
console.warn('Insufficient funds!');
|
console.warn("Insufficient funds!");
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,13 +187,13 @@ function appReducer(state = initialState, action) {
|
|||||||
transactions: [
|
transactions: [
|
||||||
...state.transactions,
|
...state.transactions,
|
||||||
{
|
{
|
||||||
type: 'Transfer',
|
type: "Transfer",
|
||||||
amount: action.payload,
|
amount: action.payload,
|
||||||
from: fromAccount,
|
from: fromAccount,
|
||||||
target: action.target,
|
target: action.target,
|
||||||
date: new Date().toLocaleString()
|
date: new Date().toLocaleString(),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
case UPDATE_ACCOUNTS:
|
case UPDATE_ACCOUNTS:
|
||||||
@ -201,20 +201,20 @@ function appReducer(state = initialState, action) {
|
|||||||
...state,
|
...state,
|
||||||
accounts: {
|
accounts: {
|
||||||
...state.accounts,
|
...state.accounts,
|
||||||
...action.payload
|
...action.payload,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
case UPDATE_PIN:
|
case UPDATE_PIN:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
pin: String(action.payload)
|
pin: String(action.payload),
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'SET_UID':
|
case "SET_UID":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
uid: action.payload
|
uid: action.payload,
|
||||||
};
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -238,13 +238,15 @@ const store = createStore(appReducer);
|
|||||||
* @param {Object} data - Event data
|
* @param {Object} data - Event data
|
||||||
*/
|
*/
|
||||||
function sendEvent(event, data) {
|
function sendEvent(event, data) {
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
A3API.SendAlert(JSON.stringify({
|
A3API.SendAlert(
|
||||||
event: event,
|
JSON.stringify({
|
||||||
data: data
|
event: event,
|
||||||
}));
|
data: data,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} 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
|
* @param {Object} data - Account data from Arma 3
|
||||||
*/
|
*/
|
||||||
function syncDataFromArma(data) {
|
function syncDataFromArma(data) {
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === "object") {
|
||||||
const accounts = {};
|
const accounts = {};
|
||||||
|
|
||||||
if (data.bank !== undefined) accounts.bank = data.bank;
|
if (data.bank !== undefined) accounts.bank = data.bank;
|
||||||
@ -268,7 +270,7 @@ function syncDataFromArma(data) {
|
|||||||
|
|
||||||
// Update UID if provided
|
// Update UID if provided
|
||||||
if (data.uid !== undefined && data.uid !== store.getState().uid) {
|
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
|
// Update pin if provided
|
||||||
@ -276,9 +278,12 @@ function syncDataFromArma(data) {
|
|||||||
store.dispatch(updatePin(data.pin));
|
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 {
|
} 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
|
// Request initial data from Arma on load
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
// Delay request slightly to ensure everything is loaded
|
// Delay request slightly to ensure everything is loaded
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sendEvent('bank::sync', {});
|
sendEvent("bank::sync", {});
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose sync function globally for Arma to call
|
// Expose sync function globally for Arma to call
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.syncDataFromArma = syncDataFromArma;
|
window.syncDataFromArma = syncDataFromArma;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_common
|
# forge_client_common
|
||||||
===================
|
|
||||||
|
|
||||||
Common functionality shared between addons.
|
Common functionality shared between addons.
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_garage
|
# forge_client_garage
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,195 +1,253 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8" />
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Vehicle Garage</title>
|
||||||
<title>Vehicle Garage</title>
|
<link rel="stylesheet" href="style.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<!--
|
||||||
<!--
|
|
||||||
Dynamic Resource Loading
|
Dynamic Resource Loading
|
||||||
The following script loads CSS and JavaScript files dynamically using the A3API
|
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
|
This approach is used instead of static HTML imports to work with Arma 3's file system
|
||||||
-->
|
-->
|
||||||
<script>
|
<script>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\garage\\ui\\_site\\style.css",
|
"forge\\forge_client\\addons\\garage\\ui\\_site\\style.css",
|
||||||
),
|
),
|
||||||
A3API.RequestFile(
|
A3API.RequestFile(
|
||||||
"forge\\forge_client\\addons\\garage\\ui\\_site\\script.js",
|
"forge\\forge_client\\addons\\garage\\ui\\_site\\script.js",
|
||||||
),
|
),
|
||||||
]).then(([css, js]) => {
|
]).then(([css, js]) => {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = css;
|
style.textContent = css;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.text = js;
|
script.text = js;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="garage-container">
|
<div class="garage-container">
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<div class="garage-header">
|
<div class="garage-header">
|
||||||
<div class="garage-logo">
|
<div class="garage-logo">
|
||||||
<div class="logo-icon">🚗</div>
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="garage-info">
|
||||||
<span class="stat-label">Active</span>
|
<h1 class="garage-title">Vehicle Garage</h1>
|
||||||
<span class="stat-value" id="activeCount">2</span>
|
<p class="garage-subtitle">Vehicle Management System</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="garage-stats">
|
||||||
<span class="stat-label">Capacity</span>
|
<div class="stat-item">
|
||||||
<span class="stat-value" id="capacityCount">20</span>
|
<span class="stat-label">Stored</span>
|
||||||
</div>
|
<span class="stat-value" id="storedCount">12</span>
|
||||||
</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>
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
<!-- Type Filter -->
|
<span class="stat-label">Active</span>
|
||||||
<div class="filter-section">
|
<span class="stat-value" id="activeCount">2</span>
|
||||||
<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>
|
||||||
|
<div class="stat-item">
|
||||||
<!-- Search -->
|
<span class="stat-label">Capacity</span>
|
||||||
<div class="filter-section">
|
<span class="stat-value" id="capacityCount">20</span>
|
||||||
<h3 class="filter-title">Search</h3>
|
|
||||||
<input type="text" class="search-input" id="searchInput" placeholder="Search vehicles...">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="header-actions">
|
||||||
|
<button class="action-btn close-btn">Close</button>
|
||||||
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Panel - Vehicle Details -->
|
<!-- Main Content -->
|
||||||
<div class="garage-panel details-panel" id="detailsPanel">
|
<div class="garage-content">
|
||||||
<div class="panel-header">
|
<!-- Left Panel - Filters -->
|
||||||
<h2 class="panel-title">Vehicle Details</h2>
|
<div class="garage-panel filters-panel">
|
||||||
</div>
|
<div class="panel-header">
|
||||||
<div class="panel-content">
|
<h2 class="panel-title">Filters</h2>
|
||||||
<div class="no-selection" id="noSelection">
|
|
||||||
<div class="no-selection-icon">🚗</div>
|
|
||||||
<p>Select a vehicle to view details</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
<div class="vehicle-details" id="vehicleDetails" style="display: none;">
|
<!-- Status Filter -->
|
||||||
<div class="detail-header">
|
<div class="filter-section">
|
||||||
<div class="detail-icon" id="detailIcon">🚗</div>
|
<h3 class="filter-title">Status</h3>
|
||||||
<div class="detail-info">
|
<div class="filter-buttons">
|
||||||
<h3 class="detail-name" id="detailName">Vehicle Name</h3>
|
<button
|
||||||
<p class="detail-type" id="detailType">Type</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<div class="detail-stats">
|
<!-- Type Filter -->
|
||||||
<div class="detail-stat">
|
<div class="filter-section">
|
||||||
<span class="detail-label">Status</span>
|
<h3 class="filter-title">Vehicle Type</h3>
|
||||||
<span class="detail-value" id="detailStatus">Stored</span>
|
<div class="type-list">
|
||||||
</div>
|
<button
|
||||||
<div class="detail-stat">
|
class="type-item active"
|
||||||
<span class="detail-label">Condition</span>
|
data-type="all"
|
||||||
<span class="detail-value" id="detailCondition">100%</span>
|
>
|
||||||
</div>
|
<span class="type-icon">📦</span>
|
||||||
<div class="detail-stat">
|
<span class="type-name">All Types</span>
|
||||||
<span class="detail-label">Fuel</span>
|
</button>
|
||||||
<span class="detail-value" id="detailFuel">100%</span>
|
<button class="type-item" data-type="car">
|
||||||
</div>
|
<span class="type-icon">🚗</span>
|
||||||
<div class="detail-stat">
|
<span class="type-name">Cars</span>
|
||||||
<span class="detail-label">Location</span>
|
</button>
|
||||||
<span class="detail-value" id="detailLocation">Garage A</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="detail-actions">
|
<!-- Search -->
|
||||||
<button class="detail-btn spawn-btn" id="spawnBtn">
|
<div class="filter-section">
|
||||||
<span class="btn-icon">🚀</span>
|
<h3 class="filter-title">Search</h3>
|
||||||
<span class="btn-text">Spawn Vehicle</span>
|
<input
|
||||||
</button>
|
type="text"
|
||||||
<button class="detail-btn store-btn" id="storeBtn" style="display: none;">
|
class="search-input"
|
||||||
<span class="btn-icon">📦</span>
|
id="searchInput"
|
||||||
<span class="btn-text">Store Vehicle</span>
|
placeholder="Search vehicles..."
|
||||||
</button>
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
<div class="detail-specs">
|
<div
|
||||||
<h4 class="specs-title">Specifications</h4>
|
class="vehicle-details"
|
||||||
<div class="specs-list">
|
id="vehicleDetails"
|
||||||
<div class="spec-item">
|
style="display: none"
|
||||||
<span class="spec-label">Seats</span>
|
>
|
||||||
<span class="spec-value" id="detailSeats">4</span>
|
<div class="detail-header">
|
||||||
|
<div class="detail-icon" id="detailIcon">
|
||||||
|
🚗
|
||||||
</div>
|
</div>
|
||||||
<div class="spec-item">
|
<div class="detail-info">
|
||||||
<span class="spec-label">Speed</span>
|
<h3 class="detail-name" id="detailName">
|
||||||
<span class="spec-value" id="detailSpeed">180 km/h</span>
|
Vehicle Name
|
||||||
|
</h3>
|
||||||
|
<p class="detail-type" id="detailType">
|
||||||
|
Type
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="spec-item">
|
</div>
|
||||||
<span class="spec-label">Cargo</span>
|
|
||||||
<span class="spec-value" id="detailCargo">200 kg</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -197,9 +255,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="script.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -7,98 +7,242 @@
|
|||||||
const mockData = {
|
const mockData = {
|
||||||
vehicles: [
|
vehicles: [
|
||||||
// Cars
|
// 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: 1,
|
||||||
{ 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" },
|
name: "Sedan",
|
||||||
{ 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" },
|
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
|
// 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: 5,
|
||||||
{ 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" },
|
name: "Pickup Truck",
|
||||||
{ 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" },
|
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
|
// 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
|
// 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
|
// State
|
||||||
let selectedVehicle = null;
|
let selectedVehicle = null;
|
||||||
let statusFilter = 'all';
|
let statusFilter = "all";
|
||||||
let typeFilter = 'all';
|
let typeFilter = "all";
|
||||||
let searchQuery = '';
|
let searchQuery = "";
|
||||||
|
|
||||||
// Icons by type
|
// Icons by type
|
||||||
const typeIcons = {
|
const typeIcons = {
|
||||||
car: '🚗',
|
car: "🚗",
|
||||||
truck: '🚛',
|
truck: "🚛",
|
||||||
air: '🚁',
|
air: "🚁",
|
||||||
sea: '🚤'
|
sea: "🚤",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
function initGarage() {
|
function initGarage() {
|
||||||
console.log('Garage interface initializing...');
|
console.log("Garage interface initializing...");
|
||||||
|
|
||||||
setupEventHandlers();
|
setupEventHandlers();
|
||||||
renderVehicles();
|
renderVehicles();
|
||||||
updateStats();
|
updateStats();
|
||||||
|
|
||||||
console.log('Garage interface initialized');
|
console.log("Garage interface initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event Handlers
|
// Event Handlers
|
||||||
function setupEventHandlers() {
|
function setupEventHandlers() {
|
||||||
// Close button
|
// Close button
|
||||||
const closeBtn = document.querySelector('.close-btn');
|
const closeBtn = document.querySelector(".close-btn");
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener("click", () => {
|
||||||
console.log('Closing garage...');
|
console.log("Closing garage...");
|
||||||
sendEvent('garage::close', {});
|
sendEvent("garage::close", {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status filters
|
// Status filters
|
||||||
const filterBtns = document.querySelectorAll('.filter-btn');
|
const filterBtns = document.querySelectorAll(".filter-btn");
|
||||||
filterBtns.forEach(btn => {
|
filterBtns.forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener("click", () => {
|
||||||
filterBtns.forEach(b => b.classList.remove('active'));
|
filterBtns.forEach((b) => b.classList.remove("active"));
|
||||||
btn.classList.add('active');
|
btn.classList.add("active");
|
||||||
statusFilter = btn.dataset.filter;
|
statusFilter = btn.dataset.filter;
|
||||||
renderVehicles();
|
renderVehicles();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type filters
|
// Type filters
|
||||||
const typeItems = document.querySelectorAll('.type-item');
|
const typeItems = document.querySelectorAll(".type-item");
|
||||||
typeItems.forEach(item => {
|
typeItems.forEach((item) => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener("click", () => {
|
||||||
typeItems.forEach(i => i.classList.remove('active'));
|
typeItems.forEach((i) => i.classList.remove("active"));
|
||||||
item.classList.add('active');
|
item.classList.add("active");
|
||||||
typeFilter = item.dataset.type;
|
typeFilter = item.dataset.type;
|
||||||
renderVehicles();
|
renderVehicles();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById("searchInput");
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener("input", (e) => {
|
||||||
searchQuery = e.target.value.toLowerCase();
|
searchQuery = e.target.value.toLowerCase();
|
||||||
renderVehicles();
|
renderVehicles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn button
|
// Spawn button
|
||||||
const spawnBtn = document.getElementById('spawnBtn');
|
const spawnBtn = document.getElementById("spawnBtn");
|
||||||
if (spawnBtn) {
|
if (spawnBtn) {
|
||||||
spawnBtn.addEventListener('click', () => {
|
spawnBtn.addEventListener("click", () => {
|
||||||
if (selectedVehicle) {
|
if (selectedVehicle) {
|
||||||
spawnVehicle(selectedVehicle);
|
spawnVehicle(selectedVehicle);
|
||||||
}
|
}
|
||||||
@ -106,9 +250,9 @@ function setupEventHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store button
|
// Store button
|
||||||
const storeBtn = document.getElementById('storeBtn');
|
const storeBtn = document.getElementById("storeBtn");
|
||||||
if (storeBtn) {
|
if (storeBtn) {
|
||||||
storeBtn.addEventListener('click', () => {
|
storeBtn.addEventListener("click", () => {
|
||||||
if (selectedVehicle) {
|
if (selectedVehicle) {
|
||||||
storeVehicle(selectedVehicle);
|
storeVehicle(selectedVehicle);
|
||||||
}
|
}
|
||||||
@ -118,38 +262,39 @@ function setupEventHandlers() {
|
|||||||
|
|
||||||
// Render vehicles
|
// Render vehicles
|
||||||
function renderVehicles() {
|
function renderVehicles() {
|
||||||
const vehiclesGrid = document.getElementById('vehiclesGrid');
|
const vehiclesGrid = document.getElementById("vehiclesGrid");
|
||||||
if (!vehiclesGrid) return;
|
if (!vehiclesGrid) return;
|
||||||
|
|
||||||
vehiclesGrid.innerHTML = '';
|
vehiclesGrid.innerHTML = "";
|
||||||
|
|
||||||
// Filter vehicles
|
// Filter vehicles
|
||||||
let filtered = mockData.vehicles;
|
let filtered = mockData.vehicles;
|
||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== "all") {
|
||||||
filtered = filtered.filter(v => v.status === statusFilter);
|
filtered = filtered.filter((v) => v.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type filter
|
// Type filter
|
||||||
if (typeFilter !== 'all') {
|
if (typeFilter !== "all") {
|
||||||
filtered = filtered.filter(v => v.type === typeFilter);
|
filtered = filtered.filter((v) => v.type === typeFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
filtered = filtered.filter(v =>
|
filtered = filtered.filter(
|
||||||
v.name.toLowerCase().includes(searchQuery) ||
|
(v) =>
|
||||||
v.type.toLowerCase().includes(searchQuery)
|
v.name.toLowerCase().includes(searchQuery) ||
|
||||||
|
v.type.toLowerCase().includes(searchQuery),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render vehicles
|
// Render vehicles
|
||||||
filtered.forEach(vehicle => {
|
filtered.forEach((vehicle) => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement("div");
|
||||||
card.className = 'vehicle-card';
|
card.className = "vehicle-card";
|
||||||
if (selectedVehicle && selectedVehicle.id === vehicle.id) {
|
if (selectedVehicle && selectedVehicle.id === vehicle.id) {
|
||||||
card.classList.add('selected');
|
card.classList.add("selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
@ -159,7 +304,7 @@ function renderVehicles() {
|
|||||||
<div class="vehicle-status ${vehicle.status}">${vehicle.status}</div>
|
<div class="vehicle-status ${vehicle.status}">${vehicle.status}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
card.addEventListener('click', () => selectVehicle(vehicle));
|
card.addEventListener("click", () => selectVehicle(vehicle));
|
||||||
vehiclesGrid.appendChild(card);
|
vehiclesGrid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -171,10 +316,10 @@ function selectVehicle(vehicle) {
|
|||||||
selectedVehicle = vehicle;
|
selectedVehicle = vehicle;
|
||||||
|
|
||||||
// Update selected state in grid
|
// Update selected state in grid
|
||||||
document.querySelectorAll('.vehicle-card').forEach(card => {
|
document.querySelectorAll(".vehicle-card").forEach((card) => {
|
||||||
card.classList.remove('selected');
|
card.classList.remove("selected");
|
||||||
});
|
});
|
||||||
event.currentTarget.classList.add('selected');
|
event.currentTarget.classList.add("selected");
|
||||||
|
|
||||||
// Show details
|
// Show details
|
||||||
showVehicleDetails(vehicle);
|
showVehicleDetails(vehicle);
|
||||||
@ -182,48 +327,49 @@ function selectVehicle(vehicle) {
|
|||||||
|
|
||||||
// Show vehicle details
|
// Show vehicle details
|
||||||
function showVehicleDetails(vehicle) {
|
function showVehicleDetails(vehicle) {
|
||||||
const noSelection = document.getElementById('noSelection');
|
const noSelection = document.getElementById("noSelection");
|
||||||
const vehicleDetails = document.getElementById('vehicleDetails');
|
const vehicleDetails = document.getElementById("vehicleDetails");
|
||||||
const spawnBtn = document.getElementById('spawnBtn');
|
const spawnBtn = document.getElementById("spawnBtn");
|
||||||
const storeBtn = document.getElementById('storeBtn');
|
const storeBtn = document.getElementById("storeBtn");
|
||||||
|
|
||||||
if (noSelection) noSelection.style.display = 'none';
|
if (noSelection) noSelection.style.display = "none";
|
||||||
if (vehicleDetails) vehicleDetails.style.display = 'flex';
|
if (vehicleDetails) vehicleDetails.style.display = "flex";
|
||||||
|
|
||||||
// Update details
|
// Update details
|
||||||
document.getElementById('detailIcon').textContent = vehicle.icon;
|
document.getElementById("detailIcon").textContent = vehicle.icon;
|
||||||
document.getElementById('detailName').textContent = vehicle.name;
|
document.getElementById("detailName").textContent = vehicle.name;
|
||||||
document.getElementById('detailType').textContent = vehicle.type;
|
document.getElementById("detailType").textContent = vehicle.type;
|
||||||
document.getElementById('detailStatus').textContent = vehicle.status;
|
document.getElementById("detailStatus").textContent = vehicle.status;
|
||||||
document.getElementById('detailCondition').textContent = `${vehicle.condition}%`;
|
document.getElementById("detailCondition").textContent =
|
||||||
document.getElementById('detailFuel').textContent = `${vehicle.fuel}%`;
|
`${vehicle.condition}%`;
|
||||||
document.getElementById('detailLocation').textContent = vehicle.location;
|
document.getElementById("detailFuel").textContent = `${vehicle.fuel}%`;
|
||||||
document.getElementById('detailSeats').textContent = vehicle.seats;
|
document.getElementById("detailLocation").textContent = vehicle.location;
|
||||||
document.getElementById('detailSpeed').textContent = vehicle.speed;
|
document.getElementById("detailSeats").textContent = vehicle.seats;
|
||||||
document.getElementById('detailCargo').textContent = vehicle.cargo;
|
document.getElementById("detailSpeed").textContent = vehicle.speed;
|
||||||
|
document.getElementById("detailCargo").textContent = vehicle.cargo;
|
||||||
|
|
||||||
// Show/hide action buttons based on status
|
// Show/hide action buttons based on status
|
||||||
if (vehicle.status === 'stored') {
|
if (vehicle.status === "stored") {
|
||||||
spawnBtn.style.display = 'flex';
|
spawnBtn.style.display = "flex";
|
||||||
storeBtn.style.display = 'none';
|
storeBtn.style.display = "none";
|
||||||
} else {
|
} else {
|
||||||
spawnBtn.style.display = 'none';
|
spawnBtn.style.display = "none";
|
||||||
storeBtn.style.display = 'flex';
|
storeBtn.style.display = "flex";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn vehicle
|
// Spawn vehicle
|
||||||
function spawnVehicle(vehicle) {
|
function spawnVehicle(vehicle) {
|
||||||
console.log('Spawning vehicle:', vehicle.name);
|
console.log("Spawning vehicle:", vehicle.name);
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
vehicle.status = 'active';
|
vehicle.status = "active";
|
||||||
vehicle.location = 'In Use';
|
vehicle.location = "In Use";
|
||||||
|
|
||||||
sendEvent('garage::spawn', {
|
sendEvent("garage::spawn", {
|
||||||
vehicleId: vehicle.id,
|
vehicleId: vehicle.id,
|
||||||
vehicleName: vehicle.name,
|
vehicleName: vehicle.name,
|
||||||
vehicleType: vehicle.type
|
vehicleType: vehicle.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-render
|
// Re-render
|
||||||
@ -236,16 +382,16 @@ function spawnVehicle(vehicle) {
|
|||||||
|
|
||||||
// Store vehicle
|
// Store vehicle
|
||||||
function storeVehicle(vehicle) {
|
function storeVehicle(vehicle) {
|
||||||
console.log('Storing vehicle:', vehicle.name);
|
console.log("Storing vehicle:", vehicle.name);
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
vehicle.status = 'stored';
|
vehicle.status = "stored";
|
||||||
vehicle.location = 'Garage A';
|
vehicle.location = "Garage A";
|
||||||
|
|
||||||
sendEvent('garage::store', {
|
sendEvent("garage::store", {
|
||||||
vehicleId: vehicle.id,
|
vehicleId: vehicle.id,
|
||||||
vehicleName: vehicle.name,
|
vehicleName: vehicle.name,
|
||||||
vehicleType: vehicle.type
|
vehicleType: vehicle.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-render
|
// Re-render
|
||||||
@ -258,13 +404,17 @@ function storeVehicle(vehicle) {
|
|||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
const stored = mockData.vehicles.filter(v => v.status === 'stored').length;
|
const stored = mockData.vehicles.filter(
|
||||||
const active = mockData.vehicles.filter(v => v.status === 'active').length;
|
(v) => v.status === "stored",
|
||||||
|
).length;
|
||||||
|
const active = mockData.vehicles.filter(
|
||||||
|
(v) => v.status === "active",
|
||||||
|
).length;
|
||||||
const capacity = mockData.vehicles.length + 6; // Mock capacity
|
const capacity = mockData.vehicles.length + 6; // Mock capacity
|
||||||
|
|
||||||
document.getElementById('storedCount').textContent = stored;
|
document.getElementById("storedCount").textContent = stored;
|
||||||
document.getElementById('activeCount').textContent = active;
|
document.getElementById("activeCount").textContent = active;
|
||||||
document.getElementById('capacityCount').textContent = capacity;
|
document.getElementById("capacityCount").textContent = capacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update garage data from external source
|
// Update garage data from external source
|
||||||
@ -276,16 +426,19 @@ function updateGarageData(data) {
|
|||||||
|
|
||||||
// Update selected vehicle if it still exists
|
// Update selected vehicle if it still exists
|
||||||
if (selectedVehicle) {
|
if (selectedVehicle) {
|
||||||
const updated = mockData.vehicles.find(v => v.id === selectedVehicle.id);
|
const updated = mockData.vehicles.find(
|
||||||
|
(v) => v.id === selectedVehicle.id,
|
||||||
|
);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
selectedVehicle = updated;
|
selectedVehicle = updated;
|
||||||
showVehicleDetails(updated);
|
showVehicleDetails(updated);
|
||||||
} else {
|
} else {
|
||||||
selectedVehicle = null;
|
selectedVehicle = null;
|
||||||
const noSelection = document.getElementById('noSelection');
|
const noSelection = document.getElementById("noSelection");
|
||||||
const vehicleDetails = document.getElementById('vehicleDetails');
|
const vehicleDetails =
|
||||||
if (noSelection) noSelection.style.display = 'flex';
|
document.getElementById("vehicleDetails");
|
||||||
if (vehicleDetails) vehicleDetails.style.display = 'none';
|
if (noSelection) noSelection.style.display = "flex";
|
||||||
|
if (vehicleDetails) vehicleDetails.style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,19 +446,21 @@ function updateGarageData(data) {
|
|||||||
|
|
||||||
// Send event to Arma
|
// Send event to Arma
|
||||||
function sendEvent(event, data) {
|
function sendEvent(event, data) {
|
||||||
if (typeof A3API !== 'undefined') {
|
if (typeof A3API !== "undefined") {
|
||||||
A3API.SendAlert(JSON.stringify({
|
A3API.SendAlert(
|
||||||
event: event,
|
JSON.stringify({
|
||||||
data: data
|
event: event,
|
||||||
}));
|
data: data,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('Event:', event, 'Data:', data);
|
console.log("Event:", event, "Data:", data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-initialize
|
// Auto-initialize
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', initGarage);
|
document.addEventListener("DOMContentLoaded", initGarage);
|
||||||
} else {
|
} else {
|
||||||
initGarage();
|
initGarage();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_locker
|
# forge_client_locker
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_main
|
# forge_client_main
|
||||||
===================
|
|
||||||
|
|
||||||
Main Addon for forge-client
|
Main Addon for forge-client
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_notifications
|
# forge_client_notifications
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,20 +1,18 @@
|
|||||||
forge_client_org
|
# forge_client_org
|
||||||
===================
|
|
||||||
|
|
||||||
Player organization UI and client integration.
|
Player organization UI and client integration.
|
||||||
|
|
||||||
UI Login Contract
|
## UI Login Contract
|
||||||
-----------------
|
|
||||||
|
|
||||||
The web UI sends the following request through `A3API.SendAlert`:
|
The web UI sends the following request through `A3API.SendAlert`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"event": "org::login::request",
|
"event": "org::login::request",
|
||||||
"data": {
|
"data": {
|
||||||
"email": "admin@spearnet.mil",
|
"email": "admin@spearnet.mil",
|
||||||
"password": "secret"
|
"password": "secret"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -81,6 +79,7 @@ _control ctrlWebBrowserAction [
|
|||||||
```
|
```
|
||||||
|
|
||||||
Current implementation:
|
Current implementation:
|
||||||
|
|
||||||
- `fnc_handleUIEvents.sqf` now handles `org::login::request`
|
- `fnc_handleUIEvents.sqf` now handles `org::login::request`
|
||||||
- success hydrates the portal with `session` + `portalData`
|
- success hydrates the portal with `session` + `portalData`
|
||||||
- failure returns a single `message` string for inline UI feedback
|
- failure returns a single `message` string for inline UI feedback
|
||||||
|
|||||||
@ -71,9 +71,9 @@
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const view = store.getView();
|
const view = store.getView();
|
||||||
const portalPermissions =
|
const portalGetters =
|
||||||
window.OrgPortal && window.OrgPortal.permissions
|
window.OrgPortal && window.OrgPortal.getters
|
||||||
? window.OrgPortal.permissions
|
? window.OrgPortal.getters
|
||||||
: null;
|
: null;
|
||||||
const portalActions =
|
const portalActions =
|
||||||
window.OrgPortal && window.OrgPortal.actions
|
window.OrgPortal && window.OrgPortal.actions
|
||||||
@ -125,9 +125,9 @@
|
|||||||
|
|
||||||
if (view === "portal" && PortalApp) {
|
if (view === "portal" && PortalApp) {
|
||||||
const canLeaveOrg =
|
const canLeaveOrg =
|
||||||
portalPermissions &&
|
portalGetters &&
|
||||||
typeof portalPermissions.canLeaveOrg === "function" &&
|
typeof portalGetters.canLeaveOrg === "function" &&
|
||||||
portalPermissions.canLeaveOrg();
|
portalGetters.canLeaveOrg();
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
"div",
|
"div",
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||||
const { portalData } = OrgPortal.data;
|
const { portalData } = OrgPortal.data;
|
||||||
const actions = OrgPortal.actions;
|
const getters = OrgPortal.getters;
|
||||||
const scopeAttr = "data-ui-assets-card";
|
const scopeAttr = "data-ui-assets-card";
|
||||||
const scopeSelector = `[${scopeAttr}]`;
|
const scopeSelector = `[${scopeAttr}]`;
|
||||||
const assetsCardCss = `
|
const assetsCardCss = `
|
||||||
@ -82,7 +82,7 @@ ${scopeSelector} .org-simple-meta {
|
|||||||
{ className: "org-simple-meta" },
|
{ className: "org-simple-meta" },
|
||||||
SimpleStat(
|
SimpleStat(
|
||||||
"Type",
|
"Type",
|
||||||
actions.formatAssetType(asset.type),
|
getters.formatAssetType(asset.type),
|
||||||
),
|
),
|
||||||
SimpleStat("Quantity", asset.quantity),
|
SimpleStat("Quantity", asset.quantity),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||||
const permissions = OrgPortal.permissions;
|
const getters = OrgPortal.getters;
|
||||||
const actions = OrgPortal.actions;
|
const actions = OrgPortal.actions;
|
||||||
const scopeAttr = "data-ui-danger-card";
|
const scopeAttr = "data-ui-danger-card";
|
||||||
const scopeSelector = `[${scopeAttr}]`;
|
const scopeSelector = `[${scopeAttr}]`;
|
||||||
@ -32,7 +32,7 @@ ${scopeSelector} .org-danger-copy p {
|
|||||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||||
ensureScopedStyle("portal-danger-card", dangerCardCss);
|
ensureScopedStyle("portal-danger-card", dangerCardCss);
|
||||||
|
|
||||||
if (!permissions.canDisbandOrg()) {
|
if (!getters.canDisbandOrg()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||||
const { portalData } = OrgPortal.data;
|
const { portalData } = OrgPortal.data;
|
||||||
const actions = OrgPortal.actions;
|
const getters = OrgPortal.getters;
|
||||||
const scopeAttr = "data-ui-fleet-card";
|
const scopeAttr = "data-ui-fleet-card";
|
||||||
const scopeSelector = `[${scopeAttr}]`;
|
const scopeSelector = `[${scopeAttr}]`;
|
||||||
const fleetCardCss = `
|
const fleetCardCss = `
|
||||||
@ -88,7 +88,7 @@ ${scopeSelector} .org-simple-meta {
|
|||||||
{ className: "org-simple-meta" },
|
{ className: "org-simple-meta" },
|
||||||
SimpleStat(
|
SimpleStat(
|
||||||
"Type",
|
"Type",
|
||||||
actions.formatVehicleType(unit.type),
|
getters.formatVehicleType(unit.type),
|
||||||
),
|
),
|
||||||
SimpleStat("Status", unit.status),
|
SimpleStat("Status", unit.status),
|
||||||
SimpleStat("Damage", unit.damage),
|
SimpleStat("Damage", unit.damage),
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
const OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||||
const store = OrgPortal.store;
|
const store = OrgPortal.store;
|
||||||
const permissions = OrgPortal.permissions;
|
const getters = OrgPortal.getters;
|
||||||
const actions = OrgPortal.actions;
|
const actions = OrgPortal.actions;
|
||||||
const scopeAttr = "data-ui-members-card";
|
const scopeAttr = "data-ui-members-card";
|
||||||
const scopeSelector = `[${scopeAttr}]`;
|
const scopeSelector = `[${scopeAttr}]`;
|
||||||
@ -56,7 +56,7 @@ ${scopeSelector} .org-name-row button {
|
|||||||
OrgPortal.componentFns.MembersCard = function MembersCard() {
|
OrgPortal.componentFns.MembersCard = function MembersCard() {
|
||||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||||
const members = store.getMembers();
|
const members = store.getMembers();
|
||||||
const allowMemberManagement = permissions.canManageMembers();
|
const allowMemberManagement = getters.canManageMembers();
|
||||||
ensureScopedStyle("portal-members-card", membersCardCss);
|
ensureScopedStyle("portal-members-card", membersCardCss);
|
||||||
|
|
||||||
return PanelCard({
|
return PanelCard({
|
||||||
@ -71,7 +71,7 @@ ${scopeSelector} .org-name-row button {
|
|||||||
...members.map((member) => {
|
...members.map((member) => {
|
||||||
const canRemoveMember =
|
const canRemoveMember =
|
||||||
allowMemberManagement &&
|
allowMemberManagement &&
|
||||||
!actions.isProtectedMember(member);
|
!getters.isProtectedMember(member);
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
"article",
|
"article",
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||||
const { portalData } = OrgPortal.data;
|
const { portalData } = OrgPortal.data;
|
||||||
const store = OrgPortal.store;
|
const store = OrgPortal.store;
|
||||||
const actions = OrgPortal.actions;
|
const getters = OrgPortal.getters;
|
||||||
const scopeAttr = "data-ui-overview-card";
|
const scopeAttr = "data-ui-overview-card";
|
||||||
const scopeSelector = `[${scopeAttr}]`;
|
const scopeSelector = `[${scopeAttr}]`;
|
||||||
const overviewCardCss = `
|
const overviewCardCss = `
|
||||||
@ -75,7 +75,7 @@ ${scopeSelector} .org-metric-grid {
|
|||||||
OrgPortal.componentFns.OverviewCard = function OverviewCard() {
|
OrgPortal.componentFns.OverviewCard = function OverviewCard() {
|
||||||
const MetricCard = OrgPortal.componentFns.MetricCard;
|
const MetricCard = OrgPortal.componentFns.MetricCard;
|
||||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||||
const readiness = actions.getAssetReadiness();
|
const readiness = getters.getAssetReadiness();
|
||||||
const headquarters = portalData.org.headquarters || "ArmA Verse";
|
const headquarters = portalData.org.headquarters || "ArmA Verse";
|
||||||
ensureScopedStyle("portal-overview-card", overviewCardCss);
|
ensureScopedStyle("portal-overview-card", overviewCardCss);
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ ${scopeSelector} .org-metric-grid {
|
|||||||
h(
|
h(
|
||||||
"span",
|
"span",
|
||||||
{ className: "org-meta-value" },
|
{ className: "org-meta-value" },
|
||||||
actions.formatDisplayName(portalData.org.owner),
|
getters.formatDisplayName(portalData.org.owner),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
@ -150,7 +150,7 @@ ${scopeSelector} .org-metric-grid {
|
|||||||
{ className: "org-metric-grid" },
|
{ className: "org-metric-grid" },
|
||||||
MetricCard(
|
MetricCard(
|
||||||
"Org Funds",
|
"Org Funds",
|
||||||
actions.formatCurrency(store.getFunds()),
|
getters.formatCurrency(store.getFunds()),
|
||||||
"Organization treasury balance",
|
"Organization treasury balance",
|
||||||
),
|
),
|
||||||
MetricCard(
|
MetricCard(
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
const { h, ensureScopedStyle, createSignal } = OrgPortal.runtime;
|
const { h, ensureScopedStyle, createSignal } = OrgPortal.runtime;
|
||||||
const { portalData } = OrgPortal.data;
|
const { portalData } = OrgPortal.data;
|
||||||
const store = OrgPortal.store;
|
const store = OrgPortal.store;
|
||||||
const permissions = OrgPortal.permissions;
|
const getters = OrgPortal.getters;
|
||||||
const actions = OrgPortal.actions;
|
const actions = OrgPortal.actions;
|
||||||
const scopeAttr = "data-ui-treasury-card";
|
const scopeAttr = "data-ui-treasury-card";
|
||||||
const scopeSelector = `[${scopeAttr}]`;
|
const scopeSelector = `[${scopeAttr}]`;
|
||||||
@ -199,7 +199,7 @@ ${scopeSelector} .org-credit-line-empty {
|
|||||||
OrgPortal.componentFns.TreasuryCard = function TreasuryCard() {
|
OrgPortal.componentFns.TreasuryCard = function TreasuryCard() {
|
||||||
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
const PanelCard = window.SharedUI.componentFns.PanelCard;
|
||||||
const creditLines = store.getCreditLines();
|
const creditLines = store.getCreditLines();
|
||||||
const allowTreasuryActions = permissions.canManageTreasury();
|
const allowTreasuryActions = getters.canManageTreasury();
|
||||||
const activeTab = getTreasuryTab();
|
const activeTab = getTreasuryTab();
|
||||||
const isMenuOpen = getTreasuryMenuOpen();
|
const isMenuOpen = getTreasuryMenuOpen();
|
||||||
const activeCreditLabel =
|
const activeCreditLabel =
|
||||||
@ -323,7 +323,7 @@ ${scopeSelector} .org-credit-line-empty {
|
|||||||
h(
|
h(
|
||||||
"strong",
|
"strong",
|
||||||
null,
|
null,
|
||||||
actions.formatCurrency(
|
getters.formatCurrency(
|
||||||
line.amount,
|
line.amount,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -353,7 +353,7 @@ ${scopeSelector} .org-credit-line-empty {
|
|||||||
h(
|
h(
|
||||||
"strong",
|
"strong",
|
||||||
null,
|
null,
|
||||||
actions.formatCurrency(store.getFunds()),
|
getters.formatCurrency(store.getFunds()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
|
|||||||
@ -11,13 +11,13 @@
|
|||||||
"runtime.js",
|
"runtime.js",
|
||||||
"logic\\registryStore.js",
|
"logic\\registryStore.js",
|
||||||
"logic\\portalStore.js",
|
"logic\\portalStore.js",
|
||||||
"logic\\portalPermissions.js",
|
"logic\\portalGetters.js",
|
||||||
"logic\\portalActions.js",
|
"logic\\portalActions.js",
|
||||||
"state.js",
|
"useRegistryStore.js",
|
||||||
"bridge.js",
|
"bridge.js",
|
||||||
"portal\\data.js",
|
"portal\\data.js",
|
||||||
"portal\\store.js",
|
"portal\\useStore.js",
|
||||||
"portal\\permissions.js",
|
"portal\\getters.js",
|
||||||
"portal\\actions.js",
|
"portal\\actions.js",
|
||||||
"components\\navbar.js",
|
"components\\navbar.js",
|
||||||
"components\\header.js",
|
"components\\header.js",
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
SharedLogic.createPortalActions = function createPortalActions({
|
SharedLogic.createPortalActions = function createPortalActions({
|
||||||
portalData,
|
portalData,
|
||||||
store,
|
store,
|
||||||
permissions,
|
getters,
|
||||||
registryStore,
|
registryStore,
|
||||||
}) {
|
}) {
|
||||||
class OrgPortalActions {
|
class OrgPortalActions {
|
||||||
@ -12,59 +12,6 @@
|
|||||||
this.treasuryNoticeTimer = null;
|
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) {
|
showTreasuryNotice(type, text) {
|
||||||
store.setTreasuryNotice({ type, text });
|
store.setTreasuryNotice({ type, text });
|
||||||
|
|
||||||
@ -88,58 +35,6 @@
|
|||||||
return el ? el.value : "";
|
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() {
|
closePortal() {
|
||||||
if (
|
if (
|
||||||
typeof A3API !== "undefined" &&
|
typeof A3API !== "undefined" &&
|
||||||
@ -164,7 +59,7 @@
|
|||||||
(type === "payroll" ||
|
(type === "payroll" ||
|
||||||
type === "transfer" ||
|
type === "transfer" ||
|
||||||
type === "credit") &&
|
type === "credit") &&
|
||||||
!permissions.canManageTreasury()
|
!getters.canManageTreasury()
|
||||||
) {
|
) {
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"error",
|
"error",
|
||||||
@ -173,11 +68,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "disband" && !permissions.canDisbandOrg()) {
|
if (type === "disband" && !getters.canDisbandOrg()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "leave" && !permissions.canLeaveOrg()) {
|
if (type === "leave" && !getters.canLeaveOrg()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,16 +84,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeMember(member) {
|
removeMember(member) {
|
||||||
if (!permissions.canManageMembers()) {
|
if (!getters.canManageMembers()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isProtectedMember(member)) {
|
if (getters.isProtectedMember(member)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberUid = this.getMemberUid(member);
|
const memberUid = getters.getMemberUid(member);
|
||||||
const memberName = this.getMemberName(member);
|
const memberName = getters.getMemberName(member);
|
||||||
|
|
||||||
store.setMembers((currentMembers) =>
|
store.setMembers((currentMembers) =>
|
||||||
currentMembers.filter((entry) =>
|
currentMembers.filter((entry) =>
|
||||||
@ -214,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
disbandOrganization() {
|
disbandOrganization() {
|
||||||
if (!permissions.canDisbandOrg()) {
|
if (!getters.canDisbandOrg()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
leaveOrganization() {
|
leaveOrganization() {
|
||||||
if (!permissions.canLeaveOrg()) {
|
if (!getters.canLeaveOrg()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
runPayroll(amountPerMember) {
|
runPayroll(amountPerMember) {
|
||||||
if (!permissions.canManageTreasury()) {
|
if (!getters.canManageTreasury()) {
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"error",
|
"error",
|
||||||
"Only the organization leader or CEO can manage treasury actions.",
|
"Only the organization leader or CEO can manage treasury actions.",
|
||||||
@ -297,13 +192,13 @@
|
|||||||
store.setFunds(funds - total);
|
store.setFunds(funds - total);
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"success",
|
"success",
|
||||||
`Payroll sent to ${members.length} members for ${this.formatCurrency(total)}.`,
|
`Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFundsToMember(memberName, amount) {
|
sendFundsToMember(memberName, amount) {
|
||||||
if (!permissions.canManageTreasury()) {
|
if (!getters.canManageTreasury()) {
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"error",
|
"error",
|
||||||
"Only the organization leader or CEO can manage treasury actions.",
|
"Only the organization leader or CEO can manage treasury actions.",
|
||||||
@ -340,13 +235,13 @@
|
|||||||
store.setFunds(funds - amount);
|
store.setFunds(funds - amount);
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"success",
|
"success",
|
||||||
`${this.formatCurrency(amount)} sent to ${memberName}.`,
|
`${getters.formatCurrency(amount)} sent to ${memberName}.`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
grantCreditLine(memberName, amount) {
|
grantCreditLine(memberName, amount) {
|
||||||
if (!permissions.canManageTreasury()) {
|
if (!getters.canManageTreasury()) {
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"error",
|
"error",
|
||||||
"Only the organization leader or CEO can manage treasury actions.",
|
"Only the organization leader or CEO can manage treasury actions.",
|
||||||
@ -391,7 +286,7 @@
|
|||||||
|
|
||||||
this.showTreasuryNotice(
|
this.showTreasuryNotice(
|
||||||
"success",
|
"success",
|
||||||
`Credit line of ${this.formatCurrency(amount)} assigned to ${memberName}.`,
|
`Credit line of ${getters.formatCurrency(amount)} assigned to ${memberName}.`,
|
||||||
);
|
);
|
||||||
return true;
|
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 OrgPortal = (window.OrgPortal = window.OrgPortal || {});
|
||||||
const { portalData } = OrgPortal.data;
|
const { portalData } = OrgPortal.data;
|
||||||
const store = OrgPortal.store;
|
const store = OrgPortal.store;
|
||||||
const permissions = OrgPortal.permissions;
|
const getters = OrgPortal.getters;
|
||||||
const registryStore = window.RegistryApp.store;
|
const registryStore = window.RegistryApp.store;
|
||||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||||
|
|
||||||
OrgPortal.actions = SharedLogic.createPortalActions({
|
OrgPortal.actions = SharedLogic.createPortalActions({
|
||||||
portalData,
|
portalData,
|
||||||
store,
|
store,
|
||||||
permissions,
|
getters,
|
||||||
registryStore,
|
registryStore,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
const { portalData, session } = OrgPortal.data;
|
const { portalData, session } = OrgPortal.data;
|
||||||
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
const SharedLogic = (window.SharedLogic = window.SharedLogic || {});
|
||||||
|
|
||||||
OrgPortal.permissions = SharedLogic.createPortalPermissions({
|
OrgPortal.getters = SharedLogic.createPortalGetters({
|
||||||
portalData,
|
portalData,
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_store
|
# forge_client_store
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
PREP(handleUIEvents);
|
PREP(handleUIEvents);
|
||||||
PREP(initStoreClass);
|
PREP(initStoreClass);
|
||||||
|
PREP(initStoreUIBridge);
|
||||||
PREP(openUI);
|
PREP(openUI);
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
#include "script_component.hpp"
|
#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 {
|
switch (_event) do {
|
||||||
case "store::close": { closeDialog 1; };
|
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]; };
|
default { hint format ["Unhandled UI event: %1", _event]; };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -27,11 +27,37 @@ GVAR(StoreClass) = createHashMapObject [[
|
|||||||
["#create", {
|
["#create", {
|
||||||
_self set ["uid", getPlayerUID player];
|
_self set ["uid", getPlayerUID player];
|
||||||
_self set ["store", createHashMap];
|
_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 ["isLoaded", false];
|
||||||
_self set ["lastSave", time];
|
_self set ["lastSave", time];
|
||||||
|
|
||||||
systemChat format ["Store class initialized for %1", (name player)];
|
systemChat format ["Store class initialized for %1", (name player)];
|
||||||
diag_log "[FORGE:Client:Store] Store Class Initialized!";
|
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 () {
|
(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) {
|
${scopeSelector} .window-titlebar {
|
||||||
const { header, workspaceNavbar, workspaceBody, cartPanel } = options;
|
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 `
|
${scopeSelector} .window-titlebar-brand {
|
||||||
<div class="store-shell">
|
display: flex;
|
||||||
<div class="window-titlebar">
|
flex-direction: column;
|
||||||
<div class="window-titlebar-brand">
|
gap: 0.1rem;
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="store-app">
|
${scopeSelector} .window-titlebar-kicker,
|
||||||
<aside class="store-sidebar">
|
${scopeSelector} .footer-title,
|
||||||
<section class="module-card search-module">
|
${scopeSelector} .eyebrow {
|
||||||
<div class="module-header">
|
font-size: 0.68rem;
|
||||||
<div>
|
letter-spacing: 0.18em;
|
||||||
<span class="eyebrow">Search</span>
|
text-transform: uppercase;
|
||||||
<h2 class="section-title">Inventory Search</h2>
|
color: var(--store-text-subtle);
|
||||||
</div>
|
font-weight: 700;
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="module-card">
|
${scopeSelector} .window-titlebar-kicker {
|
||||||
<div class="module-header">
|
color: rgb(214 227 241 / 0.72);
|
||||||
<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>
|
|
||||||
|
|
||||||
<main class="store-main">
|
${scopeSelector} .window-titlebar-title {
|
||||||
<section class="workspace-card">
|
font-size: 1.12rem;
|
||||||
${workspaceNavbar}
|
font-weight: 700;
|
||||||
<div class="workspace-header">
|
letter-spacing: -0.03em;
|
||||||
<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>
|
|
||||||
|
|
||||||
${cartPanel}
|
${scopeSelector} .window-titlebar-controls,
|
||||||
</main>
|
${scopeSelector} .module-header,
|
||||||
</div>
|
${scopeSelector} .store-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
<footer class="store-footer">
|
${scopeSelector} .window-control-btn {
|
||||||
<div class="footer-block">
|
min-width: 2rem;
|
||||||
<span class="footer-title">Procurement Desk</span>
|
height: 2rem;
|
||||||
<span class="footer-copy">Authorized supply browsing for personnel loadout preparation and mission staging.</span>
|
padding: 0 0.7rem;
|
||||||
</div>
|
border-radius: 0.45rem;
|
||||||
<div class="footer-block">
|
border: 1px solid rgb(197 220 243 / 0.16);
|
||||||
<span class="footer-title">Catalog Scope</span>
|
background: rgb(255 255 255 / 0.04);
|
||||||
<span class="footer-copy">Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.</span>
|
color: rgb(237 244 251 / 0.88);
|
||||||
</div>
|
}
|
||||||
<div class="footer-block">
|
|
||||||
<span class="footer-title">Module State</span>
|
${scopeSelector} .window-control-btn:disabled {
|
||||||
<span class="footer-copy">Search, filters, and cart presentation are staged now. Purchase logic and item wiring will follow.</span>
|
opacity: 0.5;
|
||||||
</div>
|
cursor: not-allowed;
|
||||||
</footer>
|
}
|
||||||
</div>
|
|
||||||
`;
|
${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 () {
|
(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) {
|
${scopeSelector} .catalog-grid::-webkit-scrollbar {
|
||||||
return `
|
width: 12px;
|
||||||
<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-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,
|
category,
|
||||||
slotType,
|
slotType,
|
||||||
) {
|
) {
|
||||||
return `
|
return h(
|
||||||
<button class="card-button subcategory-card" type="button" data-subcategory="${category.id}" data-subcategory-type="${slotType}">
|
"button",
|
||||||
<span class="card-label">${category.label}</span>
|
{
|
||||||
</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) {
|
StorefrontApp.componentFns.ProductCard = function ProductCard(
|
||||||
return `
|
item,
|
||||||
<article class="card-button product-card">
|
quantityInCart,
|
||||||
<div class="product-image">Image Placeholder</div>
|
) {
|
||||||
<div class="product-meta">
|
return h(
|
||||||
<span class="product-code">${item.code}</span>
|
"article",
|
||||||
<strong class="product-name">${item.name}</strong>
|
{ className: "product-card" },
|
||||||
</div>
|
h("div", { className: "product-image" }, "Image Placeholder"),
|
||||||
<p class="product-copy">${item.description}</p>
|
h(
|
||||||
<div class="product-footer">
|
"div",
|
||||||
<span class="product-price">${item.price}</span>
|
{ className: "product-meta" },
|
||||||
<button class="action-btn" type="button">Add to Cart</button>
|
h("span", { className: "product-code" }, item.code),
|
||||||
</div>
|
h("strong", { className: "product-name" }, item.name),
|
||||||
</article>
|
),
|
||||||
`;
|
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 () {
|
(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: [
|
categoryCards: [
|
||||||
{ id: "uniforms", label: "Uniforms" },
|
{ id: "uniforms", label: "Uniforms" },
|
||||||
{ id: "headgear", label: "Headgear" },
|
{ 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 addonRoot = "forge\\forge_client\\addons\\store\\ui\\_site\\";
|
||||||
const styleFiles = ["style.css"];
|
const styleFiles = ["style.css"];
|
||||||
const scriptFiles = [
|
const scriptFiles = [
|
||||||
|
"runtime.js",
|
||||||
|
"data.js",
|
||||||
|
"logic/store.js",
|
||||||
|
"pages/StoreView.js",
|
||||||
|
"useStore.js",
|
||||||
|
"bridge.js",
|
||||||
|
"logic/events.js",
|
||||||
"components/AppShell.js",
|
"components/AppShell.js",
|
||||||
"components/cards.js",
|
"components/cards.js",
|
||||||
"components/cart-panel.js",
|
"components/cart.js",
|
||||||
"components/workspace-navbar.js",
|
"components/navbar.js",
|
||||||
"data.js",
|
|
||||||
"logic/state-transitions.js",
|
|
||||||
"logic/workspace.js",
|
|
||||||
"logic/events.js",
|
|
||||||
"script.js",
|
"script.js",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,77 +1,184 @@
|
|||||||
(function () {
|
(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) {
|
let noticeTimer = null;
|
||||||
const {
|
|
||||||
state,
|
|
||||||
closeStore,
|
|
||||||
renderApp,
|
|
||||||
toggleCart,
|
|
||||||
closeCart,
|
|
||||||
navigateToBreadcrumb,
|
|
||||||
selectCategory,
|
|
||||||
selectSubcategory,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const closeBtn = document.getElementById("store-close-btn");
|
function showNotice(type, text) {
|
||||||
if (closeBtn) {
|
store.setNotice({ type, text });
|
||||||
closeBtn.addEventListener("click", closeStore);
|
|
||||||
|
if (noticeTimer) {
|
||||||
|
clearTimeout(noticeTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cartToggleBtn = document.getElementById("store-cart-toggle-btn");
|
noticeTimer = setTimeout(() => {
|
||||||
if (cartToggleBtn) {
|
store.setNotice({ type: "", text: "" });
|
||||||
cartToggleBtn.addEventListener("click", () => {
|
noticeTimer = null;
|
||||||
toggleCart(state);
|
}, 3200);
|
||||||
renderApp();
|
}
|
||||||
});
|
|
||||||
|
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");
|
showNotice("error", "Store bridge is unavailable.");
|
||||||
if (cartCloseBtn) {
|
return false;
|
||||||
cartCloseBtn.addEventListener("click", () => {
|
}
|
||||||
if (closeCart(state)) {
|
|
||||||
renderApp();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cartBackdrop = document.getElementById("store-cart-backdrop");
|
function navigateToBreadcrumb(target) {
|
||||||
if (cartBackdrop) {
|
return store.navigateToBreadcrumb(target);
|
||||||
cartBackdrop.addEventListener("click", () => {
|
}
|
||||||
if (closeCart(state)) {
|
|
||||||
renderApp();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document
|
function selectCategory(category) {
|
||||||
.querySelectorAll("[data-breadcrumb-target]")
|
store.selectCategory(category);
|
||||||
.forEach((button) => {
|
}
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const target = button.getAttribute(
|
|
||||||
"data-breadcrumb-target",
|
|
||||||
);
|
|
||||||
if (navigateToBreadcrumb(state, target)) {
|
|
||||||
renderApp();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll("[data-category]").forEach((button) => {
|
function selectSubcategory(subcategory, slotType) {
|
||||||
button.addEventListener("click", () => {
|
store.selectSubcategory(subcategory, slotType);
|
||||||
const category = button.getAttribute("data-category");
|
}
|
||||||
selectCategory(state, category);
|
|
||||||
renderApp();
|
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) => {
|
store.setCartOpen(true);
|
||||||
button.addEventListener("click", () => {
|
showNotice("success", `${item.name} added to the acquisition queue.`);
|
||||||
const subcategory = button.getAttribute("data-subcategory");
|
}
|
||||||
const slotType = button.getAttribute("data-subcategory-type");
|
|
||||||
selectSubcategory(state, subcategory, slotType);
|
function incrementCartItem(code) {
|
||||||
renderApp();
|
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 () {
|
(function () {
|
||||||
const state = {
|
function mountStorefront() {
|
||||||
view: "categories",
|
const root = document.getElementById("app");
|
||||||
selectedCategory: "",
|
if (!root) {
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[Store UI]", event, payload);
|
window.StorefrontApp.runtime.render(
|
||||||
}
|
window.StorefrontApp.components.App,
|
||||||
|
root,
|
||||||
function closeStore() {
|
|
||||||
sendEvent("store::close", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderApp() {
|
|
||||||
const header = getWorkspaceHeader(state, formatTitle);
|
|
||||||
const workspaceNavbar = components.renderWorkspaceNavbar(
|
|
||||||
state,
|
|
||||||
formatTitle,
|
|
||||||
);
|
);
|
||||||
const workspaceBody = renderWorkspaceBody(state, data, components);
|
|
||||||
const cartPanel = components.renderCartPanel(state);
|
|
||||||
|
|
||||||
document.getElementById("app").innerHTML = components.renderAppShell({
|
if (window.StorefrontApp.bridge) {
|
||||||
header,
|
window.StorefrontApp.bridge.notifyReady();
|
||||||
workspaceNavbar,
|
}
|
||||||
workspaceBody,
|
|
||||||
cartPanel,
|
|
||||||
});
|
|
||||||
|
|
||||||
bindEvents({
|
|
||||||
state,
|
|
||||||
closeStore,
|
|
||||||
renderApp,
|
|
||||||
toggleCart,
|
|
||||||
closeCart,
|
|
||||||
navigateToBreadcrumb,
|
|
||||||
selectCategory,
|
|
||||||
selectSubcategory,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", renderApp);
|
document.addEventListener("DOMContentLoaded", mountStorefront, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
renderApp();
|
mountStorefront();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -1,27 +1,20 @@
|
|||||||
:root {
|
:root {
|
||||||
--titlebar-bg: linear-gradient(180deg, #173a63 0%, #0e2c4f 100%);
|
--store-titlebar-bg: linear-gradient(180deg, #173a63 0%, #0e2c4f 100%);
|
||||||
--titlebar-border: rgba(161, 190, 224, 0.18);
|
--store-titlebar-border: rgba(161, 190, 224, 0.18);
|
||||||
--shell-bg: #e4e3df;
|
--store-shell-bg: #e4e3df;
|
||||||
--surface: #f5f3ef;
|
--store-surface: #f5f3ef;
|
||||||
--surface-alt: #ece8e2;
|
--store-surface-alt: #ece8e2;
|
||||||
--surface-strong: #ffffff;
|
--store-surface-strong: #ffffff;
|
||||||
--surface-muted: #dfe5eb;
|
--store-border: rgba(74, 91, 110, 0.2);
|
||||||
--border: rgba(74, 91, 110, 0.2);
|
--store-border-strong: rgba(20, 46, 79, 0.2);
|
||||||
--border-strong: rgba(20, 46, 79, 0.2);
|
--store-text-main: #1f2d3d;
|
||||||
--text-main: #1f2d3d;
|
--store-text-muted: #6a7787;
|
||||||
--text-muted: #6a7787;
|
--store-text-subtle: #8792a0;
|
||||||
--text-subtle: #8792a0;
|
--store-accent: #12365d;
|
||||||
--accent: #12365d;
|
--store-accent-soft: #dbe7f3;
|
||||||
--accent-soft: #dbe7f3;
|
--store-accent-line: rgba(18, 54, 93, 0.12);
|
||||||
--accent-line: rgba(18, 54, 93, 0.12);
|
--store-success: #2f7d5b;
|
||||||
--success: #2f7d5b;
|
--store-danger: #8a3d3d;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -38,8 +31,8 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
|
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
|
||||||
color: var(--text-main);
|
color: var(--store-text-main);
|
||||||
background: var(--shell-bg);
|
background: var(--store-shell-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@ -52,751 +45,47 @@ button {
|
|||||||
cursor: pointer;
|
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 {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-shell {
|
.store-btn {
|
||||||
display: flex;
|
min-height: 2.75rem;
|
||||||
flex-direction: column;
|
padding: 0.72rem 1rem;
|
||||||
width: 100%;
|
border-radius: 0.8rem;
|
||||||
height: 100%;
|
border: 1px solid var(--store-border-strong);
|
||||||
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);
|
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn,
|
.store-btn.store-btn-primary {
|
||||||
.cart-btn {
|
background: rgb(255 255 255 / 0.68);
|
||||||
height: 2.8rem;
|
color: var(--store-accent);
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.store-btn.store-btn-primary:hover {
|
||||||
min-height: 2.8rem;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover,
|
|
||||||
.cart-btn:hover,
|
|
||||||
.action-btn:hover {
|
|
||||||
background: rgb(219 231 243 / 0.88);
|
background: rgb(219 231 243 / 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-button {
|
.store-btn.store-btn-secondary {
|
||||||
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 {
|
|
||||||
background: rgb(255 255 255 / 0.42);
|
background: rgb(255 255 255 / 0.42);
|
||||||
color: var(--text-muted);
|
color: var(--store-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-ribbon {
|
.store-btn.store-btn-secondary:hover {
|
||||||
display: inline-flex;
|
background: rgb(255 255 255 / 0.6);
|
||||||
align-items: center;
|
color: var(--store-text-main);
|
||||||
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%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"path": "."
|
"path": ".",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"editor.insertSpaces": true,
|
"editor.insertSpaces": true,
|
||||||
@ -16,7 +16,7 @@
|
|||||||
"*.hpp": "arma-config",
|
"*.hpp": "arma-config",
|
||||||
"*.inc": "arma-config",
|
"*.inc": "arma-config",
|
||||||
"*.cfg": "arma-config",
|
"*.cfg": "arma-config",
|
||||||
"*.rvmat": "arma-config"
|
"*.rvmat": "arma-config",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,14 +23,16 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Initial Project Setup!
|
# Initial Project Setup!
|
||||||
|
|
||||||
Delete this section after the project has been initially set up:
|
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.
|
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*.
|
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.
|
- This should be the name of the repository on GitHub.
|
||||||
3. Find and replace all instances of `forge_client` with the mod's prefix.
|
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.
|
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.
|
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.
|
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.
|
The project is entirely **open-source** and any contributions are welcome.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
- Feature
|
- Feature
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
forge-client is licensed under [APL-ND](./LICENSE.md).
|
forge-client is licensed under [APL-ND](./LICENSE.md).
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_client_addonName
|
# forge_client_addonName
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
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
|
# Contributing Setup & Guidelines
|
||||||
|
|
||||||
## Setting up the Development Environment
|
## Setting up the Development Environment
|
||||||
|
|
||||||
### 1. Clone the repository from GitHub
|
### 1. Clone the repository from GitHub
|
||||||
|
|
||||||
### 2. Install HEMTT
|
### 2. Install HEMTT
|
||||||
|
|
||||||
The latest version of HEMTT can be installed by running:
|
The latest version of HEMTT can be installed by running:
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
winget install hemtt
|
winget install hemtt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Guidelines
|
## 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).
|
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
|
name: Bug report
|
||||||
about: Create a bug report to help us improve
|
about: Create a bug report to help us improve
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/bug
|
labels: kind/bug
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the bug
|
## Describe the bug
|
||||||
|
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
## To reproduce
|
## To reproduce
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
## Expected behavior
|
## Expected behavior
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
If applicable, add screenshots or RPT logs to help explain your problem.
|
If applicable, add screenshots or RPT logs to help explain your problem.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest a feature to be added
|
about: Suggest a feature to be added
|
||||||
title: ''
|
title: ""
|
||||||
labels: kind/feature-request
|
labels: kind/feature-request
|
||||||
---
|
---
|
||||||
|
|
||||||
## Describe the feature that you would like
|
## Describe the feature that you would like
|
||||||
|
|
||||||
A clear and concise description of the feature you'd want.
|
A clear and concise description of the feature you'd want.
|
||||||
|
|
||||||
## Possible alternatives
|
## Possible alternatives
|
||||||
|
|
||||||
Possible alternatives to your suggestion.
|
Possible alternatives to your suggestion.
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
Add any other context about the feature here.
|
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:**
|
**When merged this pull request will:**
|
||||||
|
|
||||||
- Describe what this pull request will do
|
- Describe what this pull request will do
|
||||||
- Each change in a separate line
|
- Each change in a separate line
|
||||||
|
|
||||||
### Important
|
### Important
|
||||||
|
|
||||||
- [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request.
|
- [ ] 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.
|
- [ ] [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}`.
|
- [ ] 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 that need to be addressed -->
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
- [ ] Issue
|
- [ ] Issue
|
||||||
|
|||||||
24
arma/server/.github/workflows/check.yml
vendored
24
arma/server/.github/workflows/check.yml
vendored
@ -12,17 +12,17 @@ jobs:
|
|||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the source code
|
- name: Checkout the source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Validate Config
|
- name: Validate Config
|
||||||
run: python tools/config_style_checker.py
|
run: python tools/config_style_checker.py
|
||||||
- name: Check for BOM
|
- name: Check for BOM
|
||||||
uses: arma-actions/bom-check@master
|
uses: arma-actions/bom-check@master
|
||||||
with:
|
with:
|
||||||
path: "addons"
|
path: "addons"
|
||||||
|
|
||||||
- name: Setup HEMTT
|
- name: Setup HEMTT
|
||||||
uses: arma-actions/hemtt@v1
|
uses: arma-actions/hemtt@v1
|
||||||
- name: Run HEMTT check
|
- name: Run HEMTT check
|
||||||
run: hemtt check --pedantic
|
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:
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
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
|
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.
|
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.
|
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.
|
4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||||
|
|
||||||
|
|||||||
@ -18,10 +18,13 @@
|
|||||||
The project is entirely **open-source** and any contributions are welcome.
|
The project is entirely **open-source** and any contributions are welcome.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
- Feature
|
- Feature
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Forge Server is licensed under [APL-SA](./LICENSE.md).
|
Forge Server is licensed under [APL-SA](./LICENSE.md).
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_actor
|
# forge_server_actor
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_bank
|
# forge_server_bank
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_common
|
# forge_server_common
|
||||||
===================
|
|
||||||
|
|
||||||
Common functionality shared between addons.
|
Common functionality shared between addons.
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_economy
|
# forge_server_economy
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_extension
|
# forge_server_extension
|
||||||
===================
|
|
||||||
|
|
||||||
Extension functionality shared between addons.
|
Extension functionality shared between addons.
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_garage
|
# forge_server_garage
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_locker
|
# forge_server_locker
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_main
|
# forge_server_main
|
||||||
===================
|
|
||||||
|
|
||||||
Main Addon for forge-server
|
Main Addon for forge-server
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
forge_server_org
|
# forge_server_org
|
||||||
===================
|
|
||||||
|
|
||||||
Description for this addon
|
Description for this addon
|
||||||
|
|||||||
@ -5,6 +5,7 @@ A high-performance arma-rs extension for Arma 3, featuring a **low-level Redis d
|
|||||||
## 🎯 Overview
|
## 🎯 Overview
|
||||||
|
|
||||||
The Forge Server Redis module is designed as a **foundational data access layer** that:
|
The Forge Server Redis module is designed as a **foundational data access layer** that:
|
||||||
|
|
||||||
- **Returns raw Redis responses** for maximum performance and flexibility
|
- **Returns raw Redis responses** for maximum performance and flexibility
|
||||||
- **Serves as the foundation** for higher-level game modules (actor, garage, locker, bank, etc.)
|
- **Serves as the foundation** for higher-level game modules (actor, garage, locker, bank, etc.)
|
||||||
- **Provides connection pooling** and error handling for Redis operations
|
- **Provides connection pooling** and error handling for Redis operations
|
||||||
@ -43,6 +44,7 @@ forge_server_x64.dll Extension
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Key Components
|
### Key Components
|
||||||
|
|
||||||
- **lib.rs**: Manages global Redis pool and single Tokio runtime
|
- **lib.rs**: Manages global Redis pool and single Tokio runtime
|
||||||
- **macros.rs**: Provides `redis_operation!` macro to eliminate boilerplate
|
- **macros.rs**: Provides `redis_operation!` macro to eliminate boilerplate
|
||||||
- **Operation modules**: Focus purely on Redis logic using the macro
|
- **Operation modules**: Focus purely on Redis logic using the macro
|
||||||
@ -51,12 +53,14 @@ forge_server_x64.dll Extension
|
|||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
### Raw Redis Operations
|
### Raw Redis Operations
|
||||||
|
|
||||||
- **String Operations**: SET, GET, INCR, DECR, DEL, KEYS
|
- **String Operations**: SET, GET, INCR, DECR, DEL, KEYS
|
||||||
- **Hash Operations**: HSET, HGET, HMSET, HGETALL, HDEL, HKEYS, HVALS, HLEN
|
- **Hash Operations**: HSET, HGET, HMSET, HGETALL, HDEL, HKEYS, HVALS, HLEN
|
||||||
- **List Operations**: LSET, LGET, LLEN, LRANGE, LPUSH, RPUSH, LPOP, RPOP, LTRIM, LREM
|
- **List Operations**: LSET, LGET, LLEN, LRANGE, LPUSH, RPUSH, LPOP, RPOP, LTRIM, LREM
|
||||||
- **Set Operations**: SADD, SMEMBERS, SCARD, SREM, SISMEMBER, SPOP, SRANDMEMBER
|
- **Set Operations**: SADD, SMEMBERS, SCARD, SREM, SISMEMBER, SPOP, SRANDMEMBER
|
||||||
|
|
||||||
### Performance Features
|
### Performance Features
|
||||||
|
|
||||||
- **Connection Pooling**: bb8-redis pool with configurable size and timeouts
|
- **Connection Pooling**: bb8-redis pool with configurable size and timeouts
|
||||||
- **Single Runtime**: One shared Tokio runtime for all async operations
|
- **Single Runtime**: One shared Tokio runtime for all async operations
|
||||||
- **Macro-Based**: `redis_operation!` macro eliminates boilerplate while maintaining performance
|
- **Macro-Based**: `redis_operation!` macro eliminates boilerplate while maintaining performance
|
||||||
@ -87,11 +91,13 @@ port = 6379
|
|||||||
### Fallback Behavior
|
### Fallback Behavior
|
||||||
|
|
||||||
The extension uses a robust fallback system:
|
The extension uses a robust fallback system:
|
||||||
|
|
||||||
1. **Loads `config.toml`** if present in the extension directory
|
1. **Loads `config.toml`** if present in the extension directory
|
||||||
2. **Falls back to defaults** if configuration fails or file is missing
|
2. **Falls back to defaults** if configuration fails or file is missing
|
||||||
3. **Only fails** if both config and defaults cannot establish connection
|
3. **Only fails** if both config and defaults cannot establish connection
|
||||||
|
|
||||||
**Default Settings:**
|
**Default Settings:**
|
||||||
|
|
||||||
- **Host**: `127.0.0.1`
|
- **Host**: `127.0.0.1`
|
||||||
- **Port**: `6379`
|
- **Port**: `6379`
|
||||||
- **Max Connections**: `10`
|
- **Max Connections**: `10`
|
||||||
@ -101,6 +107,7 @@ The extension uses a robust fallback system:
|
|||||||
### Common Configurations
|
### Common Configurations
|
||||||
|
|
||||||
**Development (Local Redis)**:
|
**Development (Local Redis)**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[redis]
|
[redis]
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
@ -110,6 +117,7 @@ min_connections = 1
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Production (Remote Redis with Authentication)**:
|
**Production (Remote Redis with Authentication)**:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[redis]
|
[redis]
|
||||||
host = "redis.example.com"
|
host = "redis.example.com"
|
||||||
@ -124,20 +132,24 @@ idle_timeout = 60
|
|||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
**Connection Issues:**
|
**Connection Issues:**
|
||||||
|
|
||||||
- Verify Redis server is running: `redis-cli ping`
|
- Verify Redis server is running: `redis-cli ping`
|
||||||
- Check host/port settings in `config.toml`
|
- Check host/port settings in `config.toml`
|
||||||
- Ensure firewall allows connection
|
- Ensure firewall allows connection
|
||||||
|
|
||||||
**Authentication Issues:**
|
**Authentication Issues:**
|
||||||
|
|
||||||
- Verify username/password in config
|
- Verify username/password in config
|
||||||
- Check Redis server auth settings
|
- Check Redis server auth settings
|
||||||
|
|
||||||
**Config File Issues:**
|
**Config File Issues:**
|
||||||
|
|
||||||
- Check TOML syntax with online validators
|
- Check TOML syntax with online validators
|
||||||
- Ensure quotes are properly closed
|
- Ensure quotes are properly closed
|
||||||
- Verify file permissions
|
- Verify file permissions
|
||||||
|
|
||||||
**Connection Pool Benefits:**
|
**Connection Pool Benefits:**
|
||||||
|
|
||||||
- Pre-warmed connections for zero-latency operations
|
- Pre-warmed connections for zero-latency operations
|
||||||
- Automatic connection recovery on network issues
|
- Automatic connection recovery on network issues
|
||||||
- Resource-efficient connection sharing
|
- Resource-efficient connection sharing
|
||||||
@ -146,23 +158,24 @@ idle_timeout = 60
|
|||||||
## 🔧 Installation
|
## 🔧 Installation
|
||||||
|
|
||||||
1. **Prerequisites**:
|
1. **Prerequisites**:
|
||||||
- Redis server (local or remote)
|
- Redis server (local or remote)
|
||||||
- Arma 3 server with extension support
|
- Arma 3 server with extension support
|
||||||
|
|
||||||
2. **Extension Setup**:
|
2. **Extension Setup**:
|
||||||
- Build the extension: `cargo build --release`
|
- Build the extension: `cargo build --release`
|
||||||
- Copy the compiled `forge_server_x64.dll` to your Arma 3 server
|
- Copy the compiled `forge_server_x64.dll` to your Arma 3 server
|
||||||
- Copy `config.example.toml` to `config.toml` and configure as needed
|
- Copy `config.example.toml` to `config.toml` and configure as needed
|
||||||
- Load in server config or mission
|
- Load in server config or mission
|
||||||
|
|
||||||
3. **Redis Server**:
|
3. **Redis Server**:
|
||||||
```bash
|
|
||||||
# Start Redis server
|
|
||||||
redis-server
|
|
||||||
|
|
||||||
# Verify connection
|
```bash
|
||||||
redis-cli ping
|
# Start Redis server
|
||||||
```
|
redis-server
|
||||||
|
|
||||||
|
# Verify connection
|
||||||
|
redis-cli ping
|
||||||
|
```
|
||||||
|
|
||||||
## 📝 Documentation
|
## 📝 Documentation
|
||||||
|
|
||||||
@ -184,6 +197,7 @@ idle_timeout = 60
|
|||||||
This module returns **raw Redis responses** as strings for maximum performance:
|
This module returns **raw Redis responses** as strings for maximum performance:
|
||||||
|
|
||||||
### Success Responses
|
### Success Responses
|
||||||
|
|
||||||
- **String values**: `"John"` (raw string)
|
- **String values**: `"John"` (raw string)
|
||||||
- **Numbers**: `"42"` (number as string)
|
- **Numbers**: `"42"` (number as string)
|
||||||
- **Lists/Arrays**: `"item1,item2,item3"` (comma-separated)
|
- **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)
|
- **Status**: `"OK"` (for successful SET operations)
|
||||||
|
|
||||||
### Error Responses
|
### Error Responses
|
||||||
|
|
||||||
- **Format**: `"Error: <error description>"`
|
- **Format**: `"Error: <error description>"`
|
||||||
- **Pool errors**: `"Error: Redis pool not initialized"`
|
- **Pool errors**: `"Error: Redis pool not initialized"`
|
||||||
- **Connection errors**: `"Error: <connection error>"`
|
- **Connection errors**: `"Error: <connection error>"`
|
||||||
- **Redis errors**: `"Error: <Redis operation error>"`
|
- **Redis errors**: `"Error: <Redis operation error>"`
|
||||||
|
|
||||||
### Higher-Level JSON Formatting
|
### Higher-Level JSON Formatting
|
||||||
|
|
||||||
Game modules (actor, garage, etc.) will wrap these raw responses in structured JSON for SQF consumption.
|
Game modules (actor, garage, etc.) will wrap these raw responses in structured JSON for SQF consumption.
|
||||||
|
|
||||||
## ⚙️ Macro-Based Implementation
|
## ⚙️ Macro-Based Implementation
|
||||||
@ -239,16 +255,16 @@ pub fn set_key(key: String, value: String) -> String {
|
|||||||
- **Dependencies**: arma-rs, bb8-redis, redis, tokio
|
- **Dependencies**: arma-rs, bb8-redis, redis, tokio
|
||||||
- **Architecture**: Macro-based design with single runtime and connection pool
|
- **Architecture**: Macro-based design with single runtime and connection pool
|
||||||
- **Key Patterns**:
|
- **Key Patterns**:
|
||||||
- Global state management in `lib.rs`
|
- Global state management in `lib.rs`
|
||||||
- Boilerplate elimination via `redis_operation!` macro
|
- Boilerplate elimination via `redis_operation!` macro
|
||||||
- Synchronous interfaces over async operations
|
- Synchronous interfaces over async operations
|
||||||
- Raw Redis responses for minimal overhead
|
- Raw Redis responses for minimal overhead
|
||||||
- **Testing**: Unit tests for core functionality
|
- **Testing**: Unit tests for core functionality
|
||||||
|
|
||||||
|
|
||||||
## 🚨 Error Handling
|
## 🚨 Error Handling
|
||||||
|
|
||||||
The extension provides comprehensive error handling:
|
The extension provides comprehensive error handling:
|
||||||
|
|
||||||
- Connection failures
|
- Connection failures
|
||||||
- Redis operation errors
|
- Redis operation errors
|
||||||
- Invalid parameters
|
- Invalid parameters
|
||||||
@ -259,6 +275,7 @@ All errors include descriptive messages for debugging.
|
|||||||
## 🔍 Monitoring
|
## 🔍 Monitoring
|
||||||
|
|
||||||
Connection pool status and Redis operations can be monitored through:
|
Connection pool status and Redis operations can be monitored through:
|
||||||
|
|
||||||
- Extension logs
|
- Extension logs
|
||||||
- Redis server logs
|
- Redis server logs
|
||||||
- Connection pool metrics
|
- Connection pool metrics
|
||||||
|
|||||||
@ -7,6 +7,7 @@ Complete reference for **raw Redis operations** available in the Forge Server ex
|
|||||||
## 🏗️ Implementation
|
## 🏗️ Implementation
|
||||||
|
|
||||||
All Redis operations are implemented using the `redis_operation!` macro for:
|
All Redis operations are implemented using the `redis_operation!` macro for:
|
||||||
|
|
||||||
- **Consistent Error Handling**: All functions return identical error formats
|
- **Consistent Error Handling**: All functions return identical error formats
|
||||||
- **Connection Management**: Automatic pool and connection handling
|
- **Connection Management**: Automatic pool and connection handling
|
||||||
- **Synchronous Interface**: Functions block until Redis operations complete
|
- **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
|
## 📝 Common Operations
|
||||||
|
|
||||||
### SET - Store a key-value pair
|
### SET - Store a key-value pair
|
||||||
|
|
||||||
**Command**: `redis:common:set`
|
**Command**: `redis:common:set`
|
||||||
**Parameters**: `[key, value]`
|
**Parameters**: `[key, value]`
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
**Raw Response**: `"OK"`
|
**Raw Response**: `"OK"`
|
||||||
|
|
||||||
### GET - Retrieve a value by key
|
### GET - Retrieve a value by key
|
||||||
|
|
||||||
**Command**: `redis:common:get`
|
**Command**: `redis:common:get`
|
||||||
**Parameters**: `[key]`
|
**Parameters**: `[key]`
|
||||||
|
|
||||||
@ -39,6 +42,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
**Raw Response**: `"John"` (the actual stored value)
|
**Raw Response**: `"John"` (the actual stored value)
|
||||||
|
|
||||||
### INCR - Increment a numeric value
|
### INCR - Increment a numeric value
|
||||||
|
|
||||||
**Command**: `redis:common:incr`
|
**Command**: `redis:common:incr`
|
||||||
**Parameters**: `[key, increment_amount]`
|
**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)
|
**Raw Response**: `"110"` (the new value as string)
|
||||||
|
|
||||||
### DECR - Decrement a numeric value
|
### DECR - Decrement a numeric value
|
||||||
|
|
||||||
**Command**: `redis:common:decr`
|
**Command**: `redis:common:decr`
|
||||||
**Parameters**: `[key, decrement_amount]`
|
**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)
|
**Raw Response**: `"2"` (the new value as string)
|
||||||
|
|
||||||
### DEL - Delete a key
|
### DEL - Delete a key
|
||||||
|
|
||||||
**Command**: `redis:common:del`
|
**Command**: `redis:common:del`
|
||||||
**Parameters**: `[key]`
|
**Parameters**: `[key]`
|
||||||
|
|
||||||
@ -69,8 +75,9 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
**Raw Response**: `"1"` (number of keys deleted)
|
**Raw Response**: `"1"` (number of keys deleted)
|
||||||
|
|
||||||
### KEYS - List all keys matching pattern
|
### KEYS - List all keys matching pattern
|
||||||
|
|
||||||
**Command**: `redis:common:keys`
|
**Command**: `redis:common:keys`
|
||||||
**Parameters**: `[]` (currently returns all keys with "*" pattern)
|
**Parameters**: `[]` (currently returns all keys with "\*" pattern)
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
"forge_server" callExtension ["redis:common:keys", []]
|
"forge_server" callExtension ["redis:common:keys", []]
|
||||||
@ -81,6 +88,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
## 🗂️ Hash Operations
|
## 🗂️ Hash Operations
|
||||||
|
|
||||||
### HSET - Set hash field
|
### HSET - Set hash field
|
||||||
|
|
||||||
**Command**: `redis:hash:set`
|
**Command**: `redis:hash:set`
|
||||||
**Parameters**: `[hash_key, field, value]`
|
**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)
|
**Raw Response**: `"1"` (number of fields added)
|
||||||
|
|
||||||
### HGET - Get hash field
|
### HGET - Get hash field
|
||||||
|
|
||||||
**Command**: `redis:hash:get`
|
**Command**: `redis:hash:get`
|
||||||
**Parameters**: `[hash_key, field]`
|
**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)
|
**Raw Response**: `"John"` (the field value)
|
||||||
|
|
||||||
### HMSET - Set multiple hash fields
|
### HMSET - Set multiple hash fields
|
||||||
|
|
||||||
**Command**: `redis:hash:mset`
|
**Command**: `redis:hash:mset`
|
||||||
**Parameters**: `[hash_key, [[field1, value1], [field2, value2], ...]]`
|
**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"`
|
**Raw Response**: `"OK"`
|
||||||
|
|
||||||
### HGETALL - Get all hash fields
|
### HGETALL - Get all hash fields
|
||||||
|
|
||||||
**Command**: `redis:hash:getall`
|
**Command**: `redis:hash:getall`
|
||||||
**Parameters**: `[hash_key]`
|
**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)
|
**Raw Response**: `"name,John,score,100,level,5"` (comma-separated key-value pairs)
|
||||||
|
|
||||||
### HDEL - Delete hash field
|
### HDEL - Delete hash field
|
||||||
|
|
||||||
**Command**: `redis:hash:del`
|
**Command**: `redis:hash:del`
|
||||||
**Parameters**: `[hash_key, field]`
|
**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)
|
**Raw Response**: `"1"` (number of fields removed)
|
||||||
|
|
||||||
### HKEYS - Get all hash field names
|
### HKEYS - Get all hash field names
|
||||||
|
|
||||||
**Command**: `redis:hash:keys`
|
**Command**: `redis:hash:keys`
|
||||||
**Parameters**: `[hash_key]`
|
**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)
|
**Raw Response**: `"name,score,level"` (comma-separated field names)
|
||||||
|
|
||||||
### HVALS - Get all hash values
|
### HVALS - Get all hash values
|
||||||
|
|
||||||
**Command**: `redis:hash:vals`
|
**Command**: `redis:hash:vals`
|
||||||
**Parameters**: `[hash_key]`
|
**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)
|
**Raw Response**: `"John,100,5"` (comma-separated values)
|
||||||
|
|
||||||
### HLEN - Get hash field count
|
### HLEN - Get hash field count
|
||||||
|
|
||||||
**Command**: `redis:hash:len`
|
**Command**: `redis:hash:len`
|
||||||
**Parameters**: `[hash_key]`
|
**Parameters**: `[hash_key]`
|
||||||
|
|
||||||
@ -163,6 +178,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
## 📋 List Operations
|
## 📋 List Operations
|
||||||
|
|
||||||
### LSET - Set list element by index
|
### LSET - Set list element by index
|
||||||
|
|
||||||
**Command**: `redis:list:set`
|
**Command**: `redis:list:set`
|
||||||
**Parameters**: `[list_key, index, value]`
|
**Parameters**: `[list_key, index, value]`
|
||||||
|
|
||||||
@ -173,6 +189,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
**Raw Response**: `"OK"`
|
**Raw Response**: `"OK"`
|
||||||
|
|
||||||
### LGET - Get list element by index
|
### LGET - Get list element by index
|
||||||
|
|
||||||
**Command**: `redis:list:get`
|
**Command**: `redis:list:get`
|
||||||
**Parameters**: `[list_key, index]`
|
**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)
|
**Raw Response**: `"patrol_alpha"` (the element value)
|
||||||
|
|
||||||
### LLEN - Get list length
|
### LLEN - Get list length
|
||||||
|
|
||||||
**Command**: `redis:list:len`
|
**Command**: `redis:list:len`
|
||||||
**Parameters**: `[list_key]`
|
**Parameters**: `[list_key]`
|
||||||
|
|
||||||
@ -193,6 +211,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
**Raw Response**: `"5"` (list length)
|
**Raw Response**: `"5"` (list length)
|
||||||
|
|
||||||
### LRANGE - Get list elements in range
|
### LRANGE - Get list elements in range
|
||||||
|
|
||||||
**Command**: `redis:list:range`
|
**Command**: `redis:list:range`
|
||||||
**Parameters**: `[list_key, start_index, end_index]`
|
**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)
|
**Raw Response**: `"patrol_alpha,escort_beta,defend_gamma"` (comma-separated values)
|
||||||
|
|
||||||
### LPUSH - Add element to list head
|
### LPUSH - Add element to list head
|
||||||
|
|
||||||
**Command**: `redis:list:lpush`
|
**Command**: `redis:list:lpush`
|
||||||
**Parameters**: `[list_key, value]`
|
**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)
|
**Raw Response**: `"6"` (new list length)
|
||||||
|
|
||||||
### RPUSH - Add element to list tail
|
### RPUSH - Add element to list tail
|
||||||
|
|
||||||
**Command**: `redis:list:rpush`
|
**Command**: `redis:list:rpush`
|
||||||
**Parameters**: `[list_key, value]`
|
**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)
|
**Raw Response**: `"6"` (new list length)
|
||||||
|
|
||||||
### LPOP - Remove and return element from list head
|
### LPOP - Remove and return element from list head
|
||||||
|
|
||||||
**Command**: `redis:list:lpop`
|
**Command**: `redis:list:lpop`
|
||||||
**Parameters**: `[list_key, count]`
|
**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)
|
**Raw Response**: `"player_joined"` (removed element) or `"item1,item2"` (if count > 1)
|
||||||
|
|
||||||
### RPOP - Remove and return element from list tail
|
### RPOP - Remove and return element from list tail
|
||||||
|
|
||||||
**Command**: `redis:list:rpop`
|
**Command**: `redis:list:rpop`
|
||||||
**Parameters**: `[list_key, count]`
|
**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)
|
**Raw Response**: `"new_objective"` (removed element) or `"item1,item2"` (if count > 1)
|
||||||
|
|
||||||
### LTRIM - Trim list to specified range
|
### LTRIM - Trim list to specified range
|
||||||
|
|
||||||
**Command**: `redis:list:trim`
|
**Command**: `redis:list:trim`
|
||||||
**Parameters**: `[list_key, start_index, end_index]`
|
**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"`
|
**Raw Response**: `"OK"`
|
||||||
|
|
||||||
### LREM - Remove elements from list
|
### LREM - Remove elements from list
|
||||||
|
|
||||||
**Command**: `redis:list:del`
|
**Command**: `redis:list:del`
|
||||||
**Parameters**: `[list_key, count, value]`
|
**Parameters**: `[list_key, count, value]`
|
||||||
|
|
||||||
@ -265,6 +290,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re
|
|||||||
## 🎯 Set Operations
|
## 🎯 Set Operations
|
||||||
|
|
||||||
### SADD - Add element to set
|
### SADD - Add element to set
|
||||||
|
|
||||||
**Command**: `redis:set:add`
|
**Command**: `redis:set:add`
|
||||||
**Parameters**: `[set_key, value]`
|
**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)
|
**Raw Response**: `"1"` (1 if element was added, 0 if already existed)
|
||||||
|
|
||||||
### SMEMBERS - Get all set members
|
### SMEMBERS - Get all set members
|
||||||
|
|
||||||
**Command**: `redis:set:members`
|
**Command**: `redis:set:members`
|
||||||
**Parameters**: `[set_key]`
|
**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)
|
**Raw Response**: `"player_123,player_456,player_789"` (comma-separated members)
|
||||||
|
|
||||||
### SCARD - Get set size
|
### SCARD - Get set size
|
||||||
|
|
||||||
**Command**: `redis:set:card`
|
**Command**: `redis:set:card`
|
||||||
**Parameters**: `[set_key]`
|
**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)
|
**Raw Response**: `"3"` (number of elements in set)
|
||||||
|
|
||||||
### SREM - Remove element from set
|
### SREM - Remove element from set
|
||||||
|
|
||||||
**Command**: `redis:set:del`
|
**Command**: `redis:set:del`
|
||||||
**Parameters**: `[set_key, value]`
|
**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)
|
**Raw Response**: `"1"` (1 if element was removed, 0 if didn't exist)
|
||||||
|
|
||||||
### SISMEMBER - Check if element is in set
|
### SISMEMBER - Check if element is in set
|
||||||
|
|
||||||
**Command**: `redis:set:ismember`
|
**Command**: `redis:set:ismember`
|
||||||
**Parameters**: `[set_key, value]`
|
**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)
|
**Raw Response**: `"1"` (1 if member exists, 0 if not)
|
||||||
|
|
||||||
### SPOP - Remove and return random element
|
### SPOP - Remove and return random element
|
||||||
|
|
||||||
**Command**: `redis:set:pop`
|
**Command**: `redis:set:pop`
|
||||||
**Parameters**: `[set_key]`
|
**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)
|
**Raw Response**: `"mission_alpha"` (the removed element)
|
||||||
|
|
||||||
### SRANDMEMBER - Get random element without removing
|
### SRANDMEMBER - Get random element without removing
|
||||||
|
|
||||||
**Command**: `redis:set:randmember`
|
**Command**: `redis:set:randmember`
|
||||||
**Parameters**: `[set_key]`
|
**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)
|
**Raw Response**: `"mission_beta"` (a random element)
|
||||||
|
|
||||||
### SRANDMEMBER - Get multiple random elements
|
### SRANDMEMBER - Get multiple random elements
|
||||||
|
|
||||||
**Command**: `redis:set:randmembers`
|
**Command**: `redis:set:randmembers`
|
||||||
**Parameters**: `[set_key, count]`
|
**Parameters**: `[set_key, count]`
|
||||||
|
|
||||||
@ -351,6 +384,7 @@ All commands may return error responses in this format:
|
|||||||
**Raw Error Response**: `"Error: <description>"`
|
**Raw Error Response**: `"Error: <description>"`
|
||||||
|
|
||||||
### Common Error Types
|
### Common Error Types
|
||||||
|
|
||||||
- **Pool not initialized**: `"Error: Redis pool not initialized"`
|
- **Pool not initialized**: `"Error: Redis pool not initialized"`
|
||||||
- **Connection failed**: `"Error: Connection refused (os error 61)"`
|
- **Connection failed**: `"Error: Connection refused (os error 61)"`
|
||||||
- **Key not found**: `"Error: key not found"` (for operations on non-existent keys)
|
- **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)
|
- **Index out of range**: `"Error: index out of range"` (for list operations)
|
||||||
|
|
||||||
### Error Handling in Game Modules
|
### Error Handling in Game Modules
|
||||||
|
|
||||||
Higher-level game modules should check if the response starts with `"Error: "` to distinguish between successful responses and errors.
|
Higher-level game modules should check if the response starts with `"Error: "` to distinguish between successful responses and errors.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": "Failed to connect to Redis server"
|
"error": "Failed to connect to Redis server"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Common error types:
|
Common error types:
|
||||||
|
|
||||||
- **Connection errors**: Redis server unavailable
|
- **Connection errors**: Redis server unavailable
|
||||||
- **Operation errors**: Invalid data type for operation
|
- **Operation errors**: Invalid data type for operation
|
||||||
- **Parameter errors**: Missing or invalid parameters
|
- **Parameter errors**: Missing or invalid parameters
|
||||||
@ -376,11 +412,13 @@ Common error types:
|
|||||||
## 📊 Response Fields
|
## 📊 Response Fields
|
||||||
|
|
||||||
### Common Fields
|
### Common Fields
|
||||||
|
|
||||||
- `status`: Always present - "success" or "error"
|
- `status`: Always present - "success" or "error"
|
||||||
- `key`: The Redis key being operated on
|
- `key`: The Redis key being operated on
|
||||||
- `error`: Error message (only on error responses)
|
- `error`: Error message (only on error responses)
|
||||||
|
|
||||||
### Success-Specific Fields
|
### Success-Specific Fields
|
||||||
|
|
||||||
- `data`: The retrieved data (for GET operations)
|
- `data`: The retrieved data (for GET operations)
|
||||||
- `value`: The stored value (for SET operations)
|
- `value`: The stored value (for SET operations)
|
||||||
- `was_new`: Boolean indicating if operation created new data
|
- `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
|
## 🚀 Function Behavior
|
||||||
|
|
||||||
All Redis functions are **synchronous from SQF's perspective**:
|
All Redis functions are **synchronous from SQF's perspective**:
|
||||||
|
|
||||||
- Functions **block** until Redis operation completes
|
- Functions **block** until Redis operation completes
|
||||||
- **No callbacks** or async handling needed in SQF
|
- **No callbacks** or async handling needed in SQF
|
||||||
- **Direct return values** – either data or error strings
|
- **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 Management
|
||||||
|
|
||||||
### Player Join/Leave Tracking
|
### Player Join/Leave Tracking
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// When player joins
|
// When player joins
|
||||||
_playerUID = getPlayerUID player;
|
_playerUID = getPlayerUID player;
|
||||||
@ -35,6 +37,7 @@ _playerName = name player;
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Player Statistics System
|
### Player Statistics System
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Initialize player stats
|
// Initialize player stats
|
||||||
fnc_initPlayerStats = {
|
fnc_initPlayerStats = {
|
||||||
@ -75,6 +78,7 @@ fnc_getPlayerStats = {
|
|||||||
## 🏆 Leaderboards and Rankings
|
## 🏆 Leaderboards and Rankings
|
||||||
|
|
||||||
### Global Kill Leaderboard
|
### Global Kill Leaderboard
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Add score to sorted leaderboard (using list for simplicity)
|
// Add score to sorted leaderboard (using list for simplicity)
|
||||||
fnc_updateLeaderboard = {
|
fnc_updateLeaderboard = {
|
||||||
@ -122,6 +126,7 @@ fnc_getTopPlayers = {
|
|||||||
## 🎯 Mission State Management
|
## 🎯 Mission State Management
|
||||||
|
|
||||||
### Objective System
|
### Objective System
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Set mission objectives
|
// Set mission objectives
|
||||||
fnc_initMissionObjectives = {
|
fnc_initMissionObjectives = {
|
||||||
@ -184,6 +189,7 @@ fnc_getMissionProgress = {
|
|||||||
## 🚁 Vehicle and Equipment Tracking
|
## 🚁 Vehicle and Equipment Tracking
|
||||||
|
|
||||||
### Vehicle Pool System
|
### Vehicle Pool System
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Initialize vehicle pool
|
// Initialize vehicle pool
|
||||||
fnc_initVehiclePool = {
|
fnc_initVehiclePool = {
|
||||||
@ -245,6 +251,7 @@ fnc_returnVehicle = {
|
|||||||
## 📊 Server Analytics
|
## 📊 Server Analytics
|
||||||
|
|
||||||
### Player Session Tracking
|
### Player Session Tracking
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Track player session start
|
// Track player session start
|
||||||
fnc_startPlayerSession = {
|
fnc_startPlayerSession = {
|
||||||
@ -295,6 +302,7 @@ fnc_endPlayerSession = {
|
|||||||
## 🔄 Cross-Server Communication
|
## 🔄 Cross-Server Communication
|
||||||
|
|
||||||
### Message Queue System
|
### Message Queue System
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Send message to other servers
|
// Send message to other servers
|
||||||
fnc_sendCrossServerMessage = {
|
fnc_sendCrossServerMessage = {
|
||||||
@ -355,6 +363,7 @@ fnc_checkMessages = {
|
|||||||
## 🛠️ Utility Functions
|
## 🛠️ Utility Functions
|
||||||
|
|
||||||
### Redis Helper Functions
|
### Redis Helper Functions
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
// Parse Redis response safely
|
// Parse Redis response safely
|
||||||
fnc_parseRedisResponse = {
|
fnc_parseRedisResponse = {
|
||||||
@ -400,6 +409,7 @@ _results = [_batchOps] call fnc_redisBatch;
|
|||||||
## 🎯 Best Practices
|
## 🎯 Best Practices
|
||||||
|
|
||||||
### Error Handling Pattern
|
### Error Handling Pattern
|
||||||
|
|
||||||
```sqf
|
```sqf
|
||||||
fnc_safeRedisCall = {
|
fnc_safeRedisCall = {
|
||||||
params ["_command", "_params", ["_defaultValue", nil]];
|
params ["_command", "_params", ["_defaultValue", nil]];
|
||||||
|
|||||||
@ -26,16 +26,16 @@ The Organization module handles guild/clan management, allowing players to form
|
|||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ------------------- | ------------------------------------------------------- |
|
||||||
| `org:get` | Retrieve organization data by key or ID. |
|
| `org:get` | Retrieve organization data by key or ID. |
|
||||||
| `org:create` | Create a new organization with provided JSON data. |
|
| `org:create` | Create a new organization with provided JSON data. |
|
||||||
| `org:update` | Update an existing organization with partial JSON data. |
|
| `org:update` | Update an existing organization with partial JSON data. |
|
||||||
| `org:delete` | Permanently remove an organization and its data. |
|
| `org:delete` | Permanently remove an organization and its data. |
|
||||||
| `org:exists` | Check if an organization exists. |
|
| `org:exists` | Check if an organization exists. |
|
||||||
| `org:get_members` | Retrieve a list of organization members. |
|
| `org:get_members` | Retrieve a list of organization members. |
|
||||||
| `org:add_member` | Add a member to an organization. |
|
| `org:add_member` | Add a member to an organization. |
|
||||||
| `org:remove_member` | Remove a member from an organization. |
|
| `org:remove_member` | Remove a member from an organization. |
|
||||||
|
|
||||||
### SQF Examples
|
### SQF Examples
|
||||||
|
|
||||||
@ -128,13 +128,13 @@ The Actor module handles all player-related operations, including data retrieval
|
|||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| -------------- | ------------------------------------------------ |
|
||||||
| `actor:get` | Retrieve actor data by key or UID. |
|
| `actor:get` | Retrieve actor data by key or UID. |
|
||||||
| `actor:create` | Create a new actor with provided JSON data. |
|
| `actor:create` | Create a new actor with provided JSON data. |
|
||||||
| `actor:update` | Update an existing actor with partial JSON data. |
|
| `actor:update` | Update an existing actor with partial JSON data. |
|
||||||
| `actor:exists` | Check if an actor exists in the database. |
|
| `actor:exists` | Check if an actor exists in the database. |
|
||||||
| `actor:delete` | Permanently remove an actor and their data. |
|
| `actor:delete` | Permanently remove an actor and their data. |
|
||||||
|
|
||||||
### SQF Examples
|
### 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:
|
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.
|
1. **Register the Command**: In the module file (e.g., `src/actor.rs`), add the command to the `group()` function.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub fn group() -> Group {
|
pub fn group() -> Group {
|
||||||
Group::new()
|
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.
|
2. **Implement the Handler Function**: Create the function that handles the command logic.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
|
||||||
@ -316,6 +318,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
|||||||
|
|
||||||
1. **Create the Module File**: Add `src/vehicle.rs`.
|
1. **Create the Module File**: Add `src/vehicle.rs`.
|
||||||
2. **Create the Global Service Instance**: Define a lazily initialized singleton service.
|
2. **Create the Global Service Instance**: Define a lazily initialized singleton service.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use forge_services::VehicleService;
|
use forge_services::VehicleService;
|
||||||
@ -329,6 +332,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
|||||||
VehicleService::new(repository)
|
VehicleService::new(repository)
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Register the Command**: In the module file, register the command in the `group()` function.
|
3. **Register the Command**: In the module file, register the command in the `group()` function.
|
||||||
```rust
|
```rust
|
||||||
pub fn group() -> Group {
|
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.
|
4. **Use Logging**: Import and use the generic `log` function in your handler functions.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
|
|
||||||
@ -378,6 +383,7 @@ To create a new module (e.g., `vehicle`), follow these steps:
|
|||||||
Log files are created automatically in `@forge_server/logs/{category}.log`.
|
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`.
|
5. **Register the Module** (if new): If you created a new module, add it to `src/lib.rs`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub mod vehicle;
|
pub mod vehicle;
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ graph TD
|
|||||||
```
|
```
|
||||||
|
|
||||||
This design enables:
|
This design enables:
|
||||||
|
|
||||||
- **Testability**: Repositories can use mock adapters for testing
|
- **Testability**: Repositories can use mock adapters for testing
|
||||||
- **Flexibility**: Different Redis implementations can be swapped without changing repositories
|
- **Flexibility**: Different Redis implementations can be swapped without changing repositories
|
||||||
- **Separation of Concerns**: Repository logic is independent of Redis connection details
|
- **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
|
#### Hash Operations
|
||||||
|
|
||||||
| Method | Description | Returns |
|
| Method | Description | Returns |
|
||||||
|--------|-------------|---------|
|
| -------------- | ------------------------------ | ------------------------ |
|
||||||
| `hash_mset` | Set multiple fields atomically | `Result<(), String>` |
|
| `hash_mset` | Set multiple fields atomically | `Result<(), String>` |
|
||||||
| `hash_get_all` | Get all fields and values | `Result<String, String>` |
|
| `hash_get_all` | Get all fields and values | `Result<String, String>` |
|
||||||
| `hash_get` | Get a single field value | `Result<String, String>` |
|
| `hash_get` | Get a single field value | `Result<String, String>` |
|
||||||
| `hash_del` | Delete a field | `Result<(), String>` |
|
| `hash_del` | Delete a field | `Result<(), String>` |
|
||||||
|
|
||||||
#### List Operations
|
#### List Operations
|
||||||
|
|
||||||
| Method | Description | Returns |
|
| Method | Description | Returns |
|
||||||
|--------|-------------|---------|
|
| ------------ | --------------------- | ----------------------------- |
|
||||||
| `list_rpush` | Append to list | `Result<(), String>` |
|
| `list_rpush` | Append to list | `Result<(), String>` |
|
||||||
| `list_range` | Get range of elements | `Result<Vec<String>, 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
|
#### Set Operations
|
||||||
|
|
||||||
| Method | Description | Returns |
|
| Method | Description | Returns |
|
||||||
|--------|-------------|---------|
|
| ------------- | --------------- | ----------------------------- |
|
||||||
| `set_add` | Add member | `Result<(), String>` |
|
| `set_add` | Add member | `Result<(), String>` |
|
||||||
| `set_members` | Get all members | `Result<Vec<String>, String>` |
|
| `set_members` | Get all members | `Result<Vec<String>, String>` |
|
||||||
| `set_del` | Remove member | `Result<(), String>` |
|
| `set_del` | Remove member | `Result<(), String>` |
|
||||||
|
|
||||||
#### Common Operations
|
#### Common Operations
|
||||||
|
|
||||||
| Method | Description | Returns |
|
| Method | Description | Returns |
|
||||||
|--------|-------------|---------|
|
| ------------ | ------------------- | ---------------------- |
|
||||||
| `key_exists` | Check if key exists | `Result<bool, String>` |
|
| `key_exists` | Check if key exists | `Result<bool, String>` |
|
||||||
| `delete_key` | Delete key | `Result<(), String>` |
|
| `delete_key` | Delete key | `Result<(), String>` |
|
||||||
|
|
||||||
### Usage Example
|
### 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:
|
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`.
|
1. **Check the Trait**: Ensure the method is defined in the `RedisClient` trait in `forge_shared`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// In forge_shared/src/redis_client.rs
|
// In forge_shared/src/redis_client.rs
|
||||||
pub trait RedisClient: Send + Sync {
|
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`.
|
2. **Implement the Method**: Add the implementation to `ExtensionRedisClient`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
impl RedisClient for ExtensionRedisClient {
|
impl RedisClient for ExtensionRedisClient {
|
||||||
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
|
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.
|
3. **Add Logging** (if needed): For debugging, log the operation.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
|
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
|
||||||
let result = redis::hash::hash_exists(key, field);
|
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`.
|
1. **Create the Module File**: Add `src/adapters/mock_client.rs`.
|
||||||
2. **Define the Struct**: Create the adapter struct.
|
2. **Define the Struct**: Create the adapter struct.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use forge_shared::RedisClient;
|
use forge_shared::RedisClient;
|
||||||
use std::collections::HashMap;
|
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.
|
3. **Implement the Trait**: Implement all `RedisClient` methods.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
impl RedisClient for MockRedisClient {
|
impl RedisClient for MockRedisClient {
|
||||||
fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> {
|
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`.
|
4. **Register the Module**: Add to `src/adapters/mod.rs`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub mod redis_client;
|
pub mod redis_client;
|
||||||
pub mod mock_client;
|
pub mod mock_client;
|
||||||
@ -235,18 +242,20 @@ To create a new adapter (e.g., `MockRedisClient` for testing):
|
|||||||
|
|
||||||
**Recommended Synchronization Primitives:**
|
**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 |
|
| **`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 |
|
| **`Mutex<HashMap>`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library |
|
||||||
| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate |
|
| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate |
|
||||||
|
|
||||||
**When to use each:**
|
**When to use each:**
|
||||||
|
|
||||||
- **`RwLock`**: Best for most use cases. Allows multiple concurrent readers, only blocks on writes. Use this by default.
|
- **`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).
|
- **`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.
|
- **`DashMap`**: When profiling shows `RwLock` is a bottleneck and you need lock-free performance.
|
||||||
|
|
||||||
**Why avoid `Mutex` for read-heavy workloads?**
|
**Why avoid `Mutex` for read-heavy workloads?**
|
||||||
|
|
||||||
- Blocks all threads (readers and writers) on every access
|
- Blocks all threads (readers and writers) on every access
|
||||||
- No concurrent reads possible
|
- No concurrent reads possible
|
||||||
- Can cause performance bottlenecks in high-concurrency scenarios
|
- Can cause performance bottlenecks in high-concurrency scenarios
|
||||||
|
|||||||
@ -16,6 +16,7 @@ The Redis module is organized into specialized operation groups:
|
|||||||
### Connection Pool
|
### Connection Pool
|
||||||
|
|
||||||
The module uses `bb8` for connection pooling, providing:
|
The module uses `bb8` for connection pooling, providing:
|
||||||
|
|
||||||
- **Automatic connection reuse**: Reduces overhead
|
- **Automatic connection reuse**: Reduces overhead
|
||||||
- **Configurable pool size**: Control max/min connections
|
- **Configurable pool size**: Control max/min connections
|
||||||
- **Idle timeout**: Prevents stale connections
|
- **Idle timeout**: Prevents stale connections
|
||||||
@ -41,14 +42,14 @@ Basic key-value operations for simple data storage.
|
|||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
| Command | Description | Returns |
|
| Command | Description | Returns |
|
||||||
|---------|-------------|---------|
|
| ------------------- | ------------------------- | ---------------------- |
|
||||||
| `redis:common:set` | Set a string value | "OK" |
|
| `redis:common:set` | Set a string value | "OK" |
|
||||||
| `redis:common:get` | Get a string value | Value or empty string |
|
| `redis:common:get` | Get a string value | Value or empty string |
|
||||||
| `redis:common:incr` | Increment a numeric value | New value |
|
| `redis:common:incr` | Increment a numeric value | New value |
|
||||||
| `redis:common:decr` | Decrement 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:del` | Delete a key | Number of keys removed |
|
||||||
| `redis:common:keys` | List all keys | Comma-separated keys |
|
| `redis:common:keys` | List all keys | Comma-separated keys |
|
||||||
|
|
||||||
### SQF Examples
|
### SQF Examples
|
||||||
|
|
||||||
@ -73,17 +74,17 @@ Hash operations store structured data as field-value pairs, ideal for objects an
|
|||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
| Command | Description | Returns |
|
| Command | Description | Returns |
|
||||||
|---------|-------------|---------|
|
| ------------------- | ------------------------------ | ------------------------ |
|
||||||
| `redis:hash:set` | Set a single field | 1 if new, 0 if updated |
|
| `redis:hash:set` | Set a single field | 1 if new, 0 if updated |
|
||||||
| `redis:hash:mset` | Set multiple fields atomically | "OK" |
|
| `redis:hash:mset` | Set multiple fields atomically | "OK" |
|
||||||
| `redis:hash:get` | Get a field value | Value or empty string |
|
| `redis:hash:get` | Get a field value | Value or empty string |
|
||||||
| `redis:hash:getall` | Get all fields and values | Comma-separated pairs |
|
| `redis:hash:getall` | Get all fields and values | Comma-separated pairs |
|
||||||
| `redis:hash:del` | Delete a field | Number of fields removed |
|
| `redis:hash:del` | Delete a field | Number of fields removed |
|
||||||
| `redis:hash:keys` | Get all field names | Comma-separated keys |
|
| `redis:hash:keys` | Get all field names | Comma-separated keys |
|
||||||
| `redis:hash:vals` | Get all values | Comma-separated values |
|
| `redis:hash:vals` | Get all values | Comma-separated values |
|
||||||
| `redis:hash:len` | Get number of fields | Field count |
|
| `redis:hash:len` | Get number of fields | Field count |
|
||||||
| `redis:hash:exists` | Check if field exists | "1" or "0" |
|
| `redis:hash:exists` | Check if field exists | "1" or "0" |
|
||||||
|
|
||||||
### SQF Examples
|
### SQF Examples
|
||||||
|
|
||||||
@ -118,18 +119,18 @@ List operations manage ordered collections, useful for queues, logs, and sequent
|
|||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
| Command | Description | Returns |
|
| Command | Description | Returns |
|
||||||
|---------|-------------|---------|
|
| ------------------ | --------------------- | ------------------------------ |
|
||||||
| `redis:list:set` | Set element at index | "OK" |
|
| `redis:list:set` | Set element at index | "OK" |
|
||||||
| `redis:list:get` | Get element at index | Value (base64 decoded) |
|
| `redis:list:get` | Get element at index | Value (base64 decoded) |
|
||||||
| `redis:list:len` | Get list length | Element count |
|
| `redis:list:len` | Get list length | Element count |
|
||||||
| `redis:list:range` | Get range of elements | JSON array |
|
| `redis:list:range` | Get range of elements | JSON array |
|
||||||
| `redis:list:lpush` | Prepend to list | New length |
|
| `redis:list:lpush` | Prepend to list | New length |
|
||||||
| `redis:list:rpush` | Append 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:lpop` | Remove from beginning | JSON array of removed elements |
|
||||||
| `redis:list:rpop` | Remove from end | 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:trim` | Trim to range | "OK" |
|
||||||
| `redis:list:del` | Remove by value | Number removed |
|
| `redis:list:del` | Remove by value | Number removed |
|
||||||
|
|
||||||
### SQF Examples
|
### SQF Examples
|
||||||
|
|
||||||
@ -159,16 +160,16 @@ Set operations manage unique collections, perfect for membership tracking and pr
|
|||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
| Command | Description | Returns |
|
| Command | Description | Returns |
|
||||||
|---------|-------------|---------|
|
| ----------------------- | ---------------------- | ---------------------------- |
|
||||||
| `redis:set:add` | Add member to set | 1 if new, 0 if exists |
|
| `redis:set:add` | Add member to set | 1 if new, 0 if exists |
|
||||||
| `redis:set:members` | Get all members | Comma-separated members |
|
| `redis:set:members` | Get all members | Comma-separated members |
|
||||||
| `redis:set:card` | Get member count | Cardinality |
|
| `redis:set:card` | Get member count | Cardinality |
|
||||||
| `redis:set:ismember` | Check membership | "1" or "0" |
|
| `redis:set:ismember` | Check membership | "1" or "0" |
|
||||||
| `redis:set:randmember` | Get random member | Member value |
|
| `redis:set:randmember` | Get random member | Member value |
|
||||||
| `redis:set:randmembers` | Get N random members | Comma-separated members |
|
| `redis:set:randmembers` | Get N random members | Comma-separated members |
|
||||||
| `redis:set:pop` | Remove random member | Removed member |
|
| `redis:set:pop` | Remove random member | Removed member |
|
||||||
| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found |
|
| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found |
|
||||||
|
|
||||||
### SQF Examples
|
### SQF Examples
|
||||||
|
|
||||||
@ -238,6 +239,7 @@ pub fn my_redis_command(key: String) -> String {
|
|||||||
```
|
```
|
||||||
|
|
||||||
The macro automatically:
|
The macro automatically:
|
||||||
|
|
||||||
- Acquires a connection from the pool
|
- Acquires a connection from the pool
|
||||||
- Handles lazy initialization if needed
|
- Handles lazy initialization if needed
|
||||||
- Executes the operation asynchronously
|
- Executes the operation asynchronously
|
||||||
@ -246,6 +248,7 @@ The macro automatically:
|
|||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
All Redis operations return strings:
|
All Redis operations return strings:
|
||||||
|
|
||||||
- **Success**: Operation result (e.g., "OK", value, count)
|
- **Success**: Operation result (e.g., "OK", value, count)
|
||||||
- **Error**: String starting with "Error: " followed by the error message
|
- **Error**: String starting with "Error: " followed by the error message
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"folders": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"path": "."
|
"path": ".",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"editor.insertSpaces": true,
|
"editor.insertSpaces": true,
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"*.hpp": "arma-config",
|
"*.hpp": "arma-config",
|
||||||
"*.inc": "arma-config",
|
"*.inc": "arma-config",
|
||||||
"*.cfg": "arma-config",
|
"*.cfg": "arma-config",
|
||||||
"*.rvmat": "arma-config"
|
"*.rvmat": "arma-config",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,26 @@
|
|||||||
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
const { h, ensureScopedStyle } = OrgPortal.runtime;
|
||||||
const scopeAttr = "data-ui-future-card";
|
const scopeAttr = "data-ui-future-card";
|
||||||
const ROADMAP = [
|
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: "Contracts Board",
|
||||||
{ name: "Logistics Queue", status: "Future Review", detail: "Possible future module pending a full design and scope review." },
|
status: "Planned",
|
||||||
{ name: "Permissions", status: "Future Review", detail: "Possible future module pending a full design and scope review." },
|
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 scopeSelector = `[${scopeAttr}]`;
|
||||||
const futureCardCss = `
|
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