Merge branch 'release/2025-02-14' into feature/IO-2776-cdk-fortellis

This commit is contained in:
Patrick Fic
2025-02-12 08:18:27 -08:00
643 changed files with 44041 additions and 22955 deletions

View File

@@ -5,23 +5,30 @@ orbs:
aws-s3: circleci/aws-s3@4.0.0
aws-cli: circleci/aws-cli@4.0
eb: circleci/aws-elastic-beanstalk@2.0.1
jira: circleci/jira@2.1.0
jobs:
imex-api-deploy:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
steps:
- checkout
- eb/setup
- run:
command: |
eb init imex-online-production-api -r ca-central-1 -p "Node.js 18 running on 64bit Amazon Linux 2"
eb init imex-online-production-api -r ca-central-1 -p "Node.js 22 running on 64bit Amazon Linux 2023"
eb status --verbose
eb deploy
eb status
- jira/notify:
environment: Production (ImEX) - API
environment_type: production
job_type: deployment
pipeline_id: << pipeline.id >>
pipeline_number: << pipeline.number >>
imex-hasura-migrate:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
parameters:
secret:
type: string
@@ -33,14 +40,19 @@ jobs:
- run:
name: Execute migration
command: |
npm install hasura-cli -g
curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bash
hasura migrate apply --endpoint https://db.imex.online/ --admin-secret << parameters.secret >>
hasura metadata apply --endpoint https://db.imex.online/ --admin-secret << parameters.secret >>
hasura metadata reload --endpoint https://db.imex.online/ --admin-secret << parameters.secret >>
- jira/notify:
environment: Production (ImEX) - Hasura
environment_type: production
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
imex-app-build:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
steps:
@@ -62,9 +74,10 @@ jobs:
to: "s3://imex-online-production/"
arguments: "--exclude '*.map'"
imex-app-beta-build:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
@@ -86,6 +99,12 @@ jobs:
from: dist
to: "s3://imex-online-beta/"
arguments: "--exclude '*.map'"
- jira/notify:
environment: Production (ImEX) - Front End
environment_type: production
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
rome-api-deploy:
docker:
@@ -95,14 +114,19 @@ jobs:
- eb/setup
- run:
command: |
eb init romeonline-productionapi -r us-east-2 -p "Node.js 18 running on 64bit Amazon Linux 2"
eb init romeonline-productionapi -r us-east-2 -p "Node.js 22 running on 64bit Amazon Linux 2023"
eb status --verbose
eb deploy
eb status
- jira/notify:
environment: Production (Rome) - API
environment_type: production
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
rome-hasura-migrate:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
parameters:
secret:
type: string
@@ -114,14 +138,19 @@ jobs:
- run:
name: Execute migration
command: |
npm install hasura-cli -g
curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bash
hasura migrate apply --endpoint https://db.romeonline.io/ --admin-secret << parameters.secret >>
hasura metadata apply --endpoint https://db.romeonline.io/ --admin-secret << parameters.secret >>
hasura metadata reload --endpoint https://db.romeonline.io/ --admin-secret << parameters.secret >>
- jira/notify:
environment: Production (Rome) - Hasura
environment_type: production
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
rome-app-build:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
working_directory: ~/repo/client
@@ -143,35 +172,16 @@ jobs:
from: dist
to: "s3://rome-online-production/"
arguments: "--exclude '*.map'"
promanager-app-build:
docker:
- image: cimg/node:18.18.2
working_directory: ~/repo/client
steps:
- checkout:
path: ~/repo
- run:
name: Install Dependencies
command: npm i
- run: npm run build:production:promanager
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
aws_secret_access_key: AWS_SECRET_ACCESS_KEY
region: AWS_REGION
- aws-s3/sync:
from: dist
to: "s3://promanager-production/"
arguments: "--exclude '*.map'"
- jira/notify:
environment: Production (Rome) - Front End
environment_type: production
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
test-rome-hasura-migrate:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
parameters:
secret:
type: string
@@ -183,14 +193,22 @@ jobs:
- run:
name: Execute migration
command: |
npm install hasura-cli -g
curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bash
hasura migrate apply --endpoint https://db.test.romeonline.io/ --admin-secret << parameters.secret >>
sleep 5
hasura metadata apply --endpoint https://db.test.romeonline.io/ --admin-secret << parameters.secret >>
sleep 10
hasura metadata reload --endpoint https://db.test.romeonline.io/ --admin-secret << parameters.secret >>
- jira/notify:
environment: Test (Rome) - Hasura
environment_type: testing
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
test-rome-app-build:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
working_directory: ~/repo/client
@@ -212,35 +230,16 @@ jobs:
from: dist
to: "s3://rome-online-test/"
arguments: "--exclude '*.map'"
test-promanager-app-build:
docker:
- image: cimg/node:18.18.2
working_directory: ~/repo/client
steps:
- checkout:
path: ~/repo
- run:
name: Install Dependencies
command: npm i
- run: npm run build:test:promanager
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
aws_secret_access_key: AWS_SECRET_ACCESS_KEY
region: AWS_REGION
- aws-s3/sync:
from: dist
to: "s3://promanager-testing/"
arguments: "--exclude '*.map'"
- jira/notify:
environment: Test (Rome) - Front End
environment_type: testing
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
test-hasura-migrate:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
parameters:
secret:
type: string
@@ -252,14 +251,22 @@ jobs:
- run:
name: Execute migration
command: |
npm install hasura-cli -g
curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bash
hasura migrate apply --endpoint https://db.test.bodyshop.app/ --admin-secret << parameters.secret >>
sleep 15
hasura metadata apply --endpoint https://db.test.bodyshop.app/ --admin-secret << parameters.secret >>
sleep 30
hasura metadata reload --endpoint https://db.test.bodyshop.app/ --admin-secret << parameters.secret >>
- jira/notify:
environment: Test (ImEX) - Hasura
environment_type: testing
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
imex-test-app-build:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
@@ -279,7 +286,7 @@ jobs:
imex-test-app-beta-build:
docker:
- image: cimg/node:18.18.2
- image: cimg/node:22.13.1
resource_class: large
working_directory: ~/repo/client
@@ -302,7 +309,12 @@ jobs:
from: dist
to: "s3://imex-online-test-beta/"
arguments: "--exclude '*.map'"
- jira/notify:
environment: Test (ImEX) - Front End
environment_type: testing
pipeline_id: << pipeline.id >>
job_type: deployment
pipeline_number: << pipeline.number >>
admin-app-build:
docker:
@@ -353,7 +365,7 @@ workflows:
secret: ${HASURA_PROD_SECRET}
filters:
branches:
only: master
only: master-AIO
- rome-api-deploy:
filters:
branches:
@@ -363,7 +375,7 @@ workflows:
branches:
only: master-AIO
- rome-hasura-migrate:
secret: ${HASURA_PROD_SECRET}
secret: ${HASURA_ROME_PROD_SECRET}
filters:
branches:
only: master-AIO
@@ -384,14 +396,6 @@ workflows:
filters:
branches:
only: test-AIO
- test-promanager-app-build:
filters:
branches:
only: test-AIO
- promanager-app-build:
filters:
branches:
only: master-AIO
- test-rome-hasura-migrate:
secret: ${HASURA_ROME_TEST_SECRET}
filters:

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# Directories to exclude
.circleci
.idea
.platform
.vscode
_reference
client
redis/dockerdata
hasura
node_modules
# Files to exclude
.ebignore
.editorconfig
.eslintrc.json
.gitignore
.prettierrc.js
Dockerfile
README.MD
bodyshop_translations.babel
docker-compose.yml
ecosystem.config.js
# Optional: Exclude logs and temporary files
*.log

80
.gitattributes vendored Normal file
View File

@@ -0,0 +1,80 @@
# Ensure all text files use LF for line endings
* text eol=lf
# Binary files should not be modified by Git
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.svg binary
# Fonts
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.eot binary
# Videos
*.mp4 binary
*.mov binary
*.avi binary
*.mkv binary
*.webm binary
# Audio
*.mp3 binary
*.wav binary
*.ogg binary
*.flac binary
# Archives and compressed files
*.zip binary
*.gz binary
*.tar binary
*.7z binary
*.rar binary
# PDF and documents
*.pdf binary
*.doc binary
*.docx binary
*.xls binary
*.xlsx binary
*.ppt binary
*.pptx binary
# Exclude JSON and other data files from text processing, if necessary
*.json text
*.xml text
*.csv text
# Scripts and code files should maintain LF endings
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.rb text eol=lf
*.java text eol=lf
*.php text eol=lf
# Git configuration files
.gitattributes text eol=lf
.gitignore text eol=lf
*.gitattributes text eol=lf
# Exclude some other potential binary files
*.db binary
*.sqlite binary
*.exe binary
*.dll binary

0
.localstack/.gitkeep Normal file
View File

View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Install required packages
dnf install -y fontconfig freetype
# Move to the /tmp directory for temporary download and extraction
cd /tmp
# Download the Montserrat font zip file
wget https://images.imex.online/fonts/montserrat.zip -O montserrat.zip
# Unzip the downloaded font file
unzip montserrat.zip -d montserrat
# Move the font files to the system fonts directory
mv montserrat/montserrat/*.ttf /usr/share/fonts
# Rebuild the font cache
fc-cache -fv
# Clean up
rm -rf /tmp/montserrat /tmp/montserrat.zip
echo "Montserrat fonts installed and cached successfully."

View File

@@ -0,0 +1,5 @@
#!/bin/bash
DD_API_KEY=58d91898a70c6fd659f6eea768a57976 DD_SITE="us3.datadoghq.com" bash -c "$(curl -L https://install.datadoghq.com/scripts/install_script_agent7.sh)"
echo "Datadog agent installed."

View File

@@ -1 +1,2 @@
client_max_body_size 50M;
client_max_body_size 50M;
client_body_buffer_size 5M;

15
.vscode/launch.json vendored
View File

@@ -14,6 +14,21 @@
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceRoot}/client/src"
},
{
"name": "Attach to Node.js in Docker",
"type": "node",
"request": "attach",
"address": "localhost",
"port": 9229,
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app",
"protocol": "inspector",
"restart": true,
"sourceMaps": true,
"skipFiles": [
"<node_internals>/**"
]
}
]
}

33
.vscode/settings.json vendored
View File

@@ -8,5 +8,36 @@
"pattern": "**/IMEX.xml",
"systemId": "logs/IMEX.xsd"
}
]
],
"cSpell.words": [
"antd",
"appointmentconfirmation",
"appt",
"autohouse",
"autohouseid",
"billlines",
"bodyshop",
"bodyshopid",
"bodyshops",
"CIECA",
"claimscorp",
"claimscorpid",
"Dinero",
"driveable",
"IMEX",
"imexshopid",
"jobid",
"joblines",
"Kaizen",
"labhrs",
"larhrs",
"mixdata",
"ownr",
"promanager",
"shopname",
"smartscheduling",
"timetickets",
"touchtime"
],
"eslint.workingDirectories": ["./", "./client"]
}

59
Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
# Use Amazon Linux 2023 as the base image
FROM amazonlinux:2023
# Install Git and Node.js (Amazon Linux 2023 uses the DNF package manager)
RUN dnf install -y git \
&& curl -sL https://rpm.nodesource.com/setup_22.x | bash - \
&& dnf install -y nodejs \
&& dnf clean all
# Install dependencies required by node-canvas
RUN dnf install -y \
gcc \
gcc-c++ \
cairo-devel \
pango-devel \
libjpeg-turbo-devel \
giflib-devel \
libpng-devel \
make \
python3 \
fontconfig \
freetype \
python3-pip \
wget \
unzip \
&& dnf clean all
# Install Montserrat fonts
RUN cd /tmp \
&& wget https://images.imex.online/fonts/montserrat.zip -O montserrat.zip \
&& unzip montserrat.zip -d montserrat \
&& mv montserrat/montserrat/*.ttf /usr/share/fonts \
&& fc-cache -fv \
&& rm -rf /tmp/montserrat /tmp/montserrat.zip \
&& echo "Montserrat fonts installed and cached successfully."
# Set the working directory
WORKDIR /app
# This is because our test route uses a git commit hash
RUN git config --global --add safe.directory /app
# Copy package.json and package-lock.json
COPY package.json ./
# Install Nodemon
RUN npm install -g nodemon
# Install dependencies
RUN npm i --no-package-lock
# Copy the rest of your application code
COPY . .
# Expose the port your app runs on (adjust if necessary)
EXPOSE 4000 9229
# Start the application
CMD ["nodemon", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]

View File

@@ -0,0 +1,64 @@
# Setting up External Networking and Static IP for WSL2 using Hyper-V
This guide will walk you through the steps to configure your WSL2 (Windows Subsystem for Linux) instance to use an external Hyper-V virtual switch, enabling it to connect directly to your local network. Additionally, you'll learn how to assign a static IP address to your WSL2 instance.
## Prerequisites
1. **Windows 11**
2. **Docker Desktop For Windows (Latest Version)
# Docker Setup
Inside the root of the project exists the `docker-compose.yaml` file, you can simply run
`docker-compose up` to launch the backend.
Things to note:
- When installing NPM packages, you will need to rebuild the `node-app` container
- Making changes to the server files will restart the `node-app`
# Local Stack
- LocalStack Front end (Optional) - https://apps.microsoft.com/detail/9ntrnft9zws2?hl=en-us&gl=US
- http://localhost:4566/_aws/ses will allow you to see emails sent
# Docker Commands
## General `docker-compose` Commands:
1. Bring up the services, force a rebuild of all services, and do not use the cache: `docker-compose up --build --no-cache`
2. Start Containers in Detached Mode: This will run the containers in the background (detached mode): `docker-compose up -d`
3. Stop and Remove Containers: Stops and removes the containers gracefully: `docker-compose down`
4. Stop containers without removing them: `docker-compose stop`
5. Remove Containers, Volumes, and Networks: `docker-compose down --volumes`
6. Force rebuild of containers: `docker-compose build --no-cache`
7. View running Containers: `docker-compose ps`
8. View a specific containers logs: `docker-compose logs <container-name>`
9. Scale services (multiple instances of a service): `docker-compose up --scale <container-name>=<instances number> -d`
10. Watch a specific containers logs in realtime with timestamps: `docker-compose logs -f --timestamps <container-name>`
## Volume Management Commands
1. List Docker volumes: `docker volume ls`
2. Remove Unused volumes `docker volume prune`
3. Remove specific volumes `docker volume rm <volume-name>`
4. Inspect a volume: `docker volume inspect <volume-name>`
## Container Image Management Commands:
1. List running containers: `docker ps`
2. List all containers: `docker os -a`
3. Remove Stopped containers: `docker container prune`
4. Remove a specific container: `docker container rm <container-name>`
5. Remove a specific image: `docker rmi <image-name>:<version>`
6. Remove all unused images: `docker image prune -a`
## Network Management Commands:
1. List networks: `docker network ls`
2. Inspect a specific network: `docker network inspect <network-name>`
3. Remove a specific network: `docker network rm <network-name>`
4. Remove unused networks: `docker network prune`
## Debugging and maintenance:
1. Enter a Running container: `docker exec -it <container name> /bin/bash` (could also be `/bin/sh` or for example `redis-cli` on a redis node)
2. View container resource usage: `docker stats`
3. Check Disk space used by Docker: `docker system df`
4. Remove all unused Data (Nuclear option): `docker system prune`
## Specific examples
1. To simulate a Clean state, one should run `docker system prune` followed by `docker volume prune -a`
2. You can run `docker-compose up` without the `-d` option, and you will get what is identical to the experience you were used to, this includes being able to control-c and bring the entire stack down

View File

@@ -10,5 +10,8 @@
"courtesycars": "date",
"media": "date",
"visualboard": "date",
"scoreboard": "date"
"scoreboard": "date",
"checklist": "date",
"smartscheduling" :"date",
"roguard": "date"
}

View File

@@ -0,0 +1 @@
node_modules

View File

@@ -0,0 +1,7 @@
This will connect to your dockers local stack session and render the email in HTML.
```shell
node index.js
```
http://localhost:3334

View File

@@ -0,0 +1,116 @@
// index.js
import express from 'express';
import fetch from 'node-fetch';
import {simpleParser} from 'mailparser';
const app = express();
const PORT = 3334;
app.get('/', async (req, res) => {
try {
const response = await fetch('http://localhost:4566/_aws/ses');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
const messagesHtml = await parseMessages(data.messages);
res.send(renderHtml(messagesHtml));
} catch (error) {
console.error('Error fetching messages:', error);
res.status(500).send('Error fetching messages');
}
});
async function parseMessages(messages) {
const parsedMessages = await Promise.all(
messages.map(async (message, index) => {
try {
const parsed = await simpleParser(message.RawData);
return `
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: lightgray">
<div class="shadow-md rounded-lg p-4 mb-6" style="background-color: white">
<div class="mb-2">
<span class="font-bold text-lg">Message ${index + 1}</span>
</div>
<div class="mb-2">
<span class="font-semibold">From:</span> ${message.Source}
</div>
<div class="mb-2">
<span class="font-semibold">Region:</span> ${message.Region}
</div>
<div class="mb-2">
<span class="font-semibold">Timestamp:</span> ${message.Timestamp}
</div>
</div>
<div class="prose">
${parsed.html || parsed.textAsHtml || 'No HTML content available'}
</div>
</div>
`;
} catch (error) {
console.error('Error parsing email:', error);
return `
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
<div class="mb-2">
<span class="font-bold text-lg">Message ${index + 1}</span>
</div>
<div class="mb-2">
<span class="font-semibold">From:</span> ${message.Source}
</div>
<div class="mb-2">
<span class="font-semibold">Region:</span> ${message.Region}
</div>
<div class="mb-2">
<span class="font-semibold">Timestamp:</span> ${message.Timestamp}
</div>
<div class="text-red-500">
Error parsing email content
</div>
</div>
`;
}
})
);
return parsedMessages.join('');
}
function renderHtml(messagesHtml) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Messages Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background-color: #f3f4f6;
font-family: Arial, sans-serif;
}
.container {
max-width: 800px;
margin: 50px auto;
padding: 20px;
}
.prose {
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center mb-6">Email Messages Viewer</h1>
<div id="messages-container">
${messagesHtml}
</div>
</div>
</body>
</html>
`;
}
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "localemailviewer",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.1",
"mailparser": "^3.7.1",
"node-fetch": "^3.3.2"
}
}

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IMEX IO Extractor</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
}
textarea {
width: 100%;
height: 200px;
}
.output-box {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
background-color: #f9f9f9;
min-height: 40px;
}
.copy-button {
margin-top: 10px;
}
</style>
</head>
<body>
<h1>IMEX IO Extractor</h1>
<textarea id="inputText" placeholder="Paste your text here..."></textarea>
<br>
<button onclick="extractIO()">Extract</button>
<div class="output-box" id="outputBox" contenteditable="true"></div>
<button class="copy-button" onclick="copyToClipboard()">Copy to Clipboard</button>
<script>
function extractIO() {
const inputText = document.getElementById('inputText').value;
const ioNumbers = [...new Set(inputText.match(/IO-\d{4}/g))] // Extract unique IO-#### matches
.map(io => ({ io, num: parseInt(io.split('-')[1]) })) // Extract number part for sorting
.sort((a, b) => a.num - b.num) // Sort by the number
.map(item => item.io); // Extract sorted IO-####
document.getElementById('outputBox').innerText = ioNumbers.join(', '); // Display horizontally
}
function copyToClipboard() {
const outputBox = document.getElementById('outputBox');
const range = document.createRange();
range.selectNodeContents(outputBox);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('copy');
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

20
certs/cert.pem Normal file
View File

@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDWzCCAkOgAwIBAgIUD/QBSAXy/AlJ/cS4DaPWJLpChxgwDQYJKoZIhvcNAQEL
BQAwPTELMAkGA1UEBhMCQ0ExCzAJBgNVBAgMAk9OMSEwHwYDVQQKDBhJbnRlcm5l
dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjQwOTA5MTU0MjA1WhcNMjUwOTA5MTU0MjA1
WjA9MQswCQYDVQQGEwJDQTELMAkGA1UECAwCT04xITAfBgNVBAoMGEludGVybmV0
IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AKSd0l7NJCNBwvtPU+dVPQkteg0AfC3sGqRnZMQteCRVa2oIgC4NoF3A9BK/yHbF
ZF25OnXTck5vzc8yb3v73ndfTD9ASKNoiaZE84/GFBsxqlKR8cs0qVwzuAsdijMv
vlMPNlMRyE1Rb7nR6HXGkPXNyxgMko03NXPkvIje9zRudm0Lf8L4q/hPyPkS7Mrm
/uQfAAJe+xFcupkEX2XY7r0x1C+z6E8lA1UcuhK3SHdW7CWYqp1vU5/dnnUiXwCa
GiC6Y1bCJB0pDAVISzy3JUDdINZdiqGR+y8ho3pstChf2mp/76s3N9eG9KA/qaFK
BrGk2PvCoZ8/Aj1aMsRYFHECAwEAAaNTMFEwHQYDVR0OBBYEFDLJ2fbWP4VUJgOp
PSs+NGHcVgRmMB8GA1UdIwQYMBaAFDLJ2fbWP4VUJgOpPSs+NGHcVgRmMA8GA1Ud
EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABfv5ut/y03atq0NMB0jeDY4
AvW4ukk0k1svyqxFZCw9o7m2lHb/IjmVrZG1Sj4JWrrSv0s02ccb26/t6vazNa5L
Powe3eyfHgfjTZJmgs8hyeMwKS0wWk/SPuu9JDhIJakiquqD+UVBGkHpP+XYvhDv
vhS2XRlW+aEjpUmr1oCyyrc6WbzrYRNadqEsn/AxwcMyUbht3Ugjkg+OpidcTIQp
5lv63waKo6I1vQofzBQ3L7JYsKo8kC0vAP7wkLxvzBii335uZJzzpFYFVOyVNezi
dJdazPbRYbXz4LjltdEn/SNfRuKX8ZRiN2OSo7OfSrZaMTS87SfCSFJGgQM8Yrk=
-----END CERTIFICATE-----

27
certs/id_rsa.key Normal file
View File

@@ -0,0 +1,27 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAvNl5fuVmLNv72BZNxnTqX5CHf5Xi8UxjYaYxHITSCx7blnhpVYLd
qXvcOWXzbsfjch/den73QiW4n2FYz75oGMhUGlOYzdWKA9I9Sj09Qy1R06RhwDiZGd5qaM
swEeXpkNmi2u4Qd2kJeDfUQUigjC09V81O/vrniGtQAJScfiG/itdm+Ufn09Z4MYk0HWjq
iDokNEskoEPsibYIrb+Q6vdtuPkZO+wU/smXhPtgw5ST6oQdmm/gVNsRg5XNzxrire+z1G
WatnnVL3hPnnfpnf8W589dyms7GGJwhPerSGTN1bn0T4+9C69Cd7LBJtxiuFdRmdlGLLLP
RR48Rur71wAAA9AEfVsdBH1bHQAAAAdzc2gtcnNhAAABAQC82Xl+5WYs2/vYFk3GdOpfkI
d/leLxTGNhpjEchNILHtuWeGlVgt2pe9w5ZfNux+NyH916fvdCJbifYVjPvmgYyFQaU5jN
1YoD0j1KPT1DLVHTpGHAOJkZ3mpoyzAR5emQ2aLa7hB3aQl4N9RBSKCMLT1XzU7++ueIa1
AAlJx+Ib+K12b5R+fT1ngxiTQdaOqIOiQ0SySgQ+yJtgitv5Dq9224+Rk77BT+yZeE+2DD
lJPqhB2ab+BU2xGDlc3PGuKt77PUZZq2edUveE+ed+md/xbnz13KazsYYnCE96tIZM3Vuf
RPj70Lr0J3ssEm3GK4V1GZ2UYsss9FHjxG6vvXAAAAAwEAAQAAAQAQTosSLQbMmtY9S3e9
yjyusdExcCTfhyQRu4MEHmfws+JsNMuLqbgwOVTD1AzYJQR7x0qdmDcLjCxL/uDnV16vvS
Sd/Vf1dhnryIyoS29tzI0DRG94ZKq7tBvmHp1w/jRT4KcSVnovhW9e5Rs74+SRFhr06PKI
S+wQOIv48Nwue9+QUMsMCpWgKXHx7SHNTHvnAfqdhi9O29SWlMA+v+mELZ5Cl+HU0UTt2I
A1BxOe1N8FjN7KE2viJexsl3is1PuqMkpLl/wyHBJTVzUadl6DRALJQIm7/YO5goE72YOV
Lpo27do3zjhC87dlKdATvZUzfKV0LuUVdxq/PNDZMUbBAAAAgQDShAqDZiDrdTUaGXfUVm
QzcnVNbh2/KgZh4uux9QNHST562W6cnN7qxoRwVrM4BCOk1Kl73QQZW4nDvXX3PVC5j038
8AXkcBHS9j9f4h72ue7D2jqlbHFa7aGU9zYgk9mbBF+GX3tDntkAIQjLtwOLfj1iiJ/clX
mHFUAY1V4L8AAAAIEA3E4t/v0yU5D9AOI0r17UNYqfeyDoKAEDR4QbbFjO1l0kLnEJy7Zx
Mhj18GilYg2y0P0v8dSM/oWXS8Hua2t5i9Exlv6gHhGlQ80mwYcVGIxewZ/pPeCPw0U+kt
EKUjt09m9Oe7+6xHQsTBj9hY8/vqPmQwRalZFcLdhHiDiVKTcAAACBANtykaPXdVzEFx7D
UOlsjVL7zM0EVOFXf9JJQ6BhazhmsEI2PYt3IpgGMo8cXkoUofAOIYjf421AabN1BqSO5J
XTMxM0ZV3JmLLi804Mu9h1iFrVTBdLYOMJdc2VCo1EwHWpo9SXOyjxce/znvcIOU04aZhu
TaPg816X+E+gw5JhAAAAFGRhdmVARGF2ZVJpY2hlci1JTUVYAQIDBAUG
-----END OPENSSH PRIVATE KEY-----

1
certs/id_rsa.pub Normal file
View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC82Xl+5WYs2/vYFk3GdOpfkId/leLxTGNhpjEchNILHtuWeGlVgt2pe9w5ZfNux+NyH916fvdCJbifYVjPvmgYyFQaU5jN1YoD0j1KPT1DLVHTpGHAOJkZ3mpoyzAR5emQ2aLa7hB3aQl4N9RBSKCMLT1XzU7++ueIa1AAlJx+Ib+K12b5R+fT1ngxiTQdaOqIOiQ0SySgQ+yJtgitv5Dq9224+Rk77BT+yZeE+2DDlJPqhB2ab+BU2xGDlc3PGuKt77PUZZq2edUveE+ed+md/xbnz13KazsYYnCE96tIZM3VufRPj70Lr0J3ssEm3GK4V1GZ2UYsss9FHjxG6vvX dave@DaveRicher-IMEX

12
certs/io-ftp-test.key Normal file
View File

@@ -0,0 +1,12 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS
1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQBYJnAujo17diR0fM2Ze1d1Ft6XHm5
U31pXdFEN+rGC4SoYTdZE8q3relxMS5GwwBOvgvVUuayfid2XS8ls/CMDiMBJAYqEK4CRY
PbbPB7lLnMWsF7muFhvs+SIpPQC+vtDwM2TKlxF0Y8p+iVRpvCADoggsSze7skmJWKmMTt
8jEdEOcAAAEQIyXsOSMl7DkAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ
AAAIUEAWCZwLo6Ne3YkdHzNmXtXdRbelx5uVN9aV3RRDfqxguEqGE3WRPKt63pcTEuRsMA
Tr4L1VLmsn4ndl0vJbPwjA4jASQGKhCuAkWD22zwe5S5zFrBe5rhYb7PkiKT0Avr7Q8DNk
ypcRdGPKfolUabwgA6IILEs3u7JJiVipjE7fIxHRDnAAAAQUO5dO9G7i0bxGTP0zV3eIwv
5g0NhrQJfW/bMHS6XWwaxdpr+QZ+DbBJVzZPwYC0wLMW4bJAf+kjqUnj4wGocoTeAAAAD2
lvLWZ0cC10ZXN0LWtleQECAwQ=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -0,0 +1 @@
ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAFgmcC6OjXt2JHR8zZl7V3UW3pceblTfWld0UQ36sYLhKhhN1kTyret6XExLkbDAE6+C9VS5rJ+J3ZdLyWz8IwOIwEkBioQrgJFg9ts8HuUucxawXua4WG+z5Iik9AL6+0PAzZMqXEXRjyn6JVGm8IAOiCCxLN7uySYlYqYxO3yMR0Q5w== io-ftp-test-key

12
certs/io-ftp-test.ppk Normal file
View File

@@ -0,0 +1,12 @@
PuTTY-User-Key-File-3: ecdsa-sha2-nistp521
Encryption: none
Comment: io-ftp-test-key
Public-Lines: 4
AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAFgmcC6OjXt
2JHR8zZl7V3UW3pceblTfWld0UQ36sYLhKhhN1kTyret6XExLkbDAE6+C9VS5rJ+
J3ZdLyWz8IwOIwEkBioQrgJFg9ts8HuUucxawXua4WG+z5Iik9AL6+0PAzZMqXEX
Rjyn6JVGm8IAOiCCxLN7uySYlYqYxO3yMR0Q5w==
Private-Lines: 2
AAAAQUO5dO9G7i0bxGTP0zV3eIwv5g0NhrQJfW/bMHS6XWwaxdpr+QZ+DbBJVzZP
wYC0wLMW4bJAf+kjqUnj4wGocoTe
Private-MAC: d67001d47e13c43dc8bdb9c68a25356a96c1c4a6714f3c5a1836fca646b78b54

28
certs/key.pem Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCkndJezSQjQcL7
T1PnVT0JLXoNAHwt7BqkZ2TELXgkVWtqCIAuDaBdwPQSv8h2xWRduTp103JOb83P
Mm97+953X0w/QEijaImmRPOPxhQbMapSkfHLNKlcM7gLHYozL75TDzZTEchNUW+5
0eh1xpD1zcsYDJKNNzVz5LyI3vc0bnZtC3/C+Kv4T8j5EuzK5v7kHwACXvsRXLqZ
BF9l2O69MdQvs+hPJQNVHLoSt0h3VuwlmKqdb1Of3Z51Il8AmhogumNWwiQdKQwF
SEs8tyVA3SDWXYqhkfsvIaN6bLQoX9pqf++rNzfXhvSgP6mhSgaxpNj7wqGfPwI9
WjLEWBRxAgMBAAECggEAUNpHYlLFxh9dokujPUMreF+Cy/IKDBAkQc2au5RNpyLh
YDIOqw/8TTAhcTgLQPLQygvZP9f8E7RsVLFD+pSJ/v2qmIJ9au1Edor1Sg+S/oxV
SLrwFMunx2aLpcH7iAqSI3+cQg7A3+D4zD7iOz6tIl3Su9wo+v073tFhHKTOrEwv
Qgr9Jf3viIiKV1ym+uQEVQndocfsj46FnFpXTQ2qs7kAF6FgAOLDGfQQwzkiqEBD
NsqsDmbYIx6foZL+DEz1ZVO2M5B+xxpbNK82KwuQilVpimW8ui4LZHCe+RIFzt9+
BK6KGlLpSEwTFliivI3nahy18JzskZsfyah0CPZlQQKBgQDVv+A0qIPGvOP3Sx+9
HyeQCV23SkvvSvw8p8pMB0gvwv63YdJ7N/rJzBGS6YUHFWWZZgEeTgkJ6VJvoe0r
8JL1el9uSUa7f0eayjmFBOGuzpktNVdIn2Tg7A9MWA4JqPNNC69RMOh86ewGD4J3
a8Hz2a1bHxAmy/AZt2ukypY6eQKBgQDFJ7kqeOPkRBz9WbALRgVIXo8YWf5di0sQ
r0HC03GAISHQ725A2IFBPHJWeqj0jaMiIZD0y+Obgp7KAskrJaLfsd7Ug775kFfw
oUI9UAl6kRuPKvm3BaVAm46SQm+56VsgxTi73YN0NUp75THHZgAJjepF9zSpVJxq
VY9DjEGruQKBgQCQCpGIatcCol/tUg69X7VFd0pULhkl1J5OMbQ9r9qRdRI5eg5h
QsQaIQ7mtb8TmvOwf/DY/zVQHI+U8sXlCmW+TwzoQTENQSR7xzMj1LpRFqBaustr
AR72A537kItFLzll/i3SxOam5uxK2UDOQSuerF4KPdCglGXkrpo3nt3F4QKBgQCa
RArPAOjQo7tLQfJN3+wiRFsTYtd1uphx5bA/EdOtvj8HjVFnzADXWsTchf3N3UXY
XwtdgGwIMpys1KEz8a8P+c2x26SDAj7NOmDqOMYx8Xju/WGHpBM6Cn30U6e4gK+d
ZLSPyzQgqdIuP5hDvbwpvbGiLVw3Ys1BJtGCuSxpgQJ/eHnRiuSi5Zq5jGg+GpA+
FEEc9NCy772rR+4uzEOqyIwqewffqzSuVWuIsY/8MP3fh+NDxl/mU6cB5QVeD54Z
JZUKwmpM26muiM6WvVnM4ExPdSGA2+l4pZjby/KKd6F/w0tgZ1jb9Pb2/0vN3qVA
Y4U4XNTMt2fxUACqiL4SHA==
-----END PRIVATE KEY-----

View File

@@ -1,5 +1,5 @@
VITE_APP_GRAPHQL_ENDPOINT=https://db.dev.bodyshop.app/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.dev.bodyshop.app/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT=https://db.dev.imex.online/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.dev.imex.online/v1/graphql
VITE_APP_GA_CODE=231099835
VITE_APP_FIREBASE_CONFIG={"apiKey":"AIzaSyDPLT8GiDHDR1R4nI66Qi0BY1aYviDPioc","authDomain":"imex-dev.firebaseapp.com","databaseURL":"https://imex-dev.firebaseio.com","projectId":"imex-dev","storageBucket":"imex-dev.appspot.com","messagingSenderId":"759548147434","appId":"1:759548147434:web:e8239868a48ceb36700993","measurementId":"G-K5XRBVVB4S"}
VITE_APP_CLOUDINARY_ENDPOINT_API=https://api.cloudinary.com/v1_1/io-test
@@ -8,7 +8,7 @@ VITE_APP_CLOUDINARY_API_KEY=957865933348715
VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BG3tzU7L2BXlGZ_3VLK4PNaRceoEXEnmHfxcVbRMF5o5g05ejslhVPki9kBM9cBBT-08Ad9kN3HSpS6JmrWD6h4'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=http://localhost:4000
VITE_APP_AXIOS_BASE_API_URL=/api/
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=IMEX

View File

@@ -1,14 +0,0 @@
VITE_APP_GRAPHQL_ENDPOINT=https://db.dev.bodyshop.app/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.dev.bodyshop.app/v1/graphql
VITE_APP_GA_CODE=231099835
VITE_APP_FIREBASE_CONFIG={"apiKey":"AIzaSyDPLT8GiDHDR1R4nI66Qi0BY1aYviDPioc","authDomain":"imex-dev.firebaseapp.com","databaseURL":"https://imex-dev.firebaseio.com","projectId":"imex-dev","storageBucket":"imex-dev.appspot.com","messagingSenderId":"759548147434","appId":"1:759548147434:web:e8239868a48ceb36700993","measurementId":"G-K5XRBVVB4S"}
VITE_APP_CLOUDINARY_ENDPOINT_API=https://api.cloudinary.com/v1_1/io-test
VITE_APP_CLOUDINARY_ENDPOINT=https://res.cloudinary.com/io-test
VITE_APP_CLOUDINARY_API_KEY=957865933348715
VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BG3tzU7L2BXlGZ_3VLK4PNaRceoEXEnmHfxcVbRMF5o5g05ejslhVPki9kBM9cBBT-08Ad9kN3HSpS6JmrWD6h4'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=http://localhost:4000
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=PROMANAGER

View File

@@ -1,14 +1,15 @@
VITE_APP_GRAPHQL_ENDPOINT=https://db.dev.bodyshop.app/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.dev.bodyshop.app/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT=https://db.dev.imex.online/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.dev.imex.online/v1/graphql
VITE_APP_GA_CODE=231099835
VITE_APP_FIREBASE_CONFIG={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"}
# VITE_APP_FIREBASE_CONFIG={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"}
VITE_APP_FIREBASE_CONFIG={"apiKey":"AIzaSyDPLT8GiDHDR1R4nI66Qi0BY1aYviDPioc","authDomain":"imex-dev.firebaseapp.com","databaseURL":"https://imex-dev.firebaseio.com","projectId":"imex-dev","storageBucket":"imex-dev.appspot.com","messagingSenderId":"759548147434","appId":"1:759548147434:web:e8239868a48ceb36700993","measurementId":"G-K5XRBVVB4S"}
VITE_APP_CLOUDINARY_ENDPOINT_API=https://api.cloudinary.com/v1_1/io-test
VITE_APP_CLOUDINARY_ENDPOINT=https://res.cloudinary.com/io-test
VITE_APP_CLOUDINARY_API_KEY=957865933348715
VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BP1B7ZTYpn-KMt6nOxlld6aS8Skt3Q7ZLEqP0hAvGHxG4UojPYiXZ6kPlzZkUC5jH-EcWXomTLtmadAIxurfcHo'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=http://localhost:4000
VITE_APP_AXIOS_BASE_API_URL=/api/
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_COUNTRY=USA

View File

@@ -1,15 +0,0 @@
GENERATE_SOURCEMAP=true
VITE_APP_GRAPHQL_ENDPOINT=https://db.romeonline.io/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.romeonline.io/v1/graphql
VITE_APP_GA_CODE=231103507
VITE_APP_FIREBASE_CONFIG={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"}
VITE_APP_CLOUDINARY_ENDPOINT_API=https://api.cloudinary.com/v1_1/bodyshop
VITE_APP_CLOUDINARY_ENDPOINT=https://res.cloudinary.com/bodyshop
VITE_APP_CLOUDINARY_API_KEY=473322739956866
VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BMgZT1NZztW2DsJl8Mg2L04hgY9FzAg6b8fbzgNAfww2VDzH3VE63Ot9EaP_U7KWS2JT-7HPHaw0T_Tw_5vkZc8'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=https://api.romeonline.io/
VITE_APP_REPORTS_SERVER_URL=https://reports.romeonline.io
VITE_APP_SPLIT_API=et9pjkik6bn67he5evpmpr1agoo7gactphgk
VITE_APP_INSTANCE=PROMANAGER

View File

@@ -1,15 +0,0 @@
VITE_APP_GRAPHQL_ENDPOINT=https://db.test.romeonline.io/v1/graphql
VITE_APP_GRAPHQL_ENDPOINT_WS=wss://db.test.romeonline.io/v1/graphql
VITE_APP_GA_CODE=231099835
VITE_APP_FIREBASE_CONFIG={ "apiKey": "AIzaSyAuLQR9SV5LsVxjU8wh9hvFLdhcAHU6cxE", "authDomain": "rome-prod-1.firebaseapp.com", "projectId": "rome-prod-1", "storageBucket": "rome-prod-1.appspot.com", "messagingSenderId": "147786367145", "appId": "1:147786367145:web:9d4cba68071c3f29a8a9b8", "measurementId": "G-G8Z9DRHTZS"}
VITE_APP_CLOUDINARY_ENDPOINT_API=https://api.cloudinary.com/v1_1/bodyshop
VITE_APP_CLOUDINARY_ENDPOINT=https://res.cloudinary.com/bodyshop
VITE_APP_CLOUDINARY_API_KEY=473322739956866
VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BN2GcDPjipR5MTEosO5dT4CfQ3cmrdBIsI4juoOQrRijn_5aRiHlwj1mlq0W145mOusx6xynEKl_tvYJhpCc9lo'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=https://api.test.romeonline.io/
VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
VITE_APP_IS_TEST=true
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=PROMANAGER

1
client/.gitignore vendored
View File

@@ -1,3 +1,4 @@
# Sentry Config File
.sentryclirc
/dev-dist

View File

@@ -12,6 +12,6 @@ module.exports = defineConfig({
setupNodeEvents(on, config) {
return require("./cypress/plugins/index.js")(on, config);
},
baseUrl: "http://localhost:3000"
baseUrl: "https://localhost:3000"
}
});

21
client/eslint.config.js Normal file
View File

@@ -0,0 +1,21 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
/** @type {import('eslint').Linter.Config[]} */
export default [
{
files: ["**/*.{js,mjs,cjs,jsx}"]
},
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
{
...pluginReact.configs.flat.recommended,
rules: {
...pluginReact.configs.flat.recommended.rules,
"react/prop-types": 0
}
},
pluginReact.configs.flat["jsx-runtime"]
];

View File

@@ -1,26 +1,26 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<% if (env.VITE_APP_INSTANCE === 'IMEX') { %>
<link rel="icon" href="/favicon.png" />
<% } %> <% if (env.VITE_APP_INSTANCE === 'ROME') { %>
<link rel="icon" href="/ro-favicon.png" />
<% } %> <% if (env.VITE_APP_INSTANCE === 'PROMANAGER') { %>
<link rel="icon" href="/pm/pm-favicon.ico" />
<% } %>
<head>
<meta charset="utf-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<% if (env.VITE_APP_INSTANCE === 'IMEX') { %>
<link rel="icon" href="/favicon.png" />
<% } %> <% if (env.VITE_APP_INSTANCE === 'ROME') { %>
<link rel="icon" href="/ro-favicon.png" />
<% } %>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1690ff" />
<!-- <link rel="apple-touch-icon" href="logo192.png" /> -->
<!-- TODO:AIo Update the individual logos for each.-->
<link rel="apple-touch-icon" href="public/logo192.png" />
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<!--
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1690ff" />
<!-- <link rel="apple-touch-icon" href="logo192.png" /> -->
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<!--
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
@@ -29,146 +29,100 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<% if (env.VITE_APP_INSTANCE === 'IMEX') { %>
<meta name="description" content="ImEX Online" />
<title>ImEX Online</title>
<script type="text/javascript">
window.$crisp = [];
window.CRISP_WEBSITE_ID = '36724f62-2eb0-4b29-9cdd-9905fb99913e';
(function () {
d = document;
s = d.createElement('script');
s.src = 'https://client.crisp.chat/l.js';
s.async = 1;
d.getElementsByTagName('head')[0].appendChild(s);
})();
</script>
<% } %> <% if (env.VITE_APP_INSTANCE === 'ROME') { %>
<meta name="description" content="Rome Online" />
<title>Rome Online</title>
<% if (env.VITE_APP_INSTANCE === 'IMEX') { %>
<meta name="description" content="ImEX Online" />
<title>ImEX Online</title>
<script type="text/javascript">
window.$crisp = [];
window.CRISP_WEBSITE_ID = "36724f62-2eb0-4b29-9cdd-9905fb99913e";
(function () {
d = document;
s = d.createElement("script");
s.src = "https://client.crisp.chat/l.js";
s.async = 1;
d.getElementsByTagName("head")[0].appendChild(s);
})();
</script>
<% } %> <% if (env.VITE_APP_INSTANCE === 'ROME') { %>
<meta name="description" content="Rome Online" />
<title>Rome Online</title>
<script type="text/javascript" id="zsiqchat">
var $zoho = $zoho || {};
$zoho.salesiq = $zoho.salesiq || {
widgetcode: "siq01bb8ac617280bdacddfeb528f07734dadc64ef3f05efef9f769c1ec171af666",
values: {},
ready: function () {}
};
var d = document;
s = d.createElement("script");
s.type = "text/javascript";
s.id = "zsiqscript";
s.defer = true;
s.src = "https://salesiq.zohopublic.com/widget";
t = d.getElementsByTagName("script")[0];
t.parentNode.insertBefore(s, t);
</script>
<script type="text/javascript">
window.$crisp = [];
window.CRISP_WEBSITE_ID = "36724f62-2eb0-4b29-9cdd-9905fb99913e";
(function () {
d = document;
s = d.createElement("script");
s.src = "https://client.crisp.chat/l.js";
s.async = 1;
d.getElementsByTagName("head")[0].appendChild(s);
})();
</script>
<% } %>
<script>
!(function () {
"use strict";
var e = [
"debug",
"destroy",
"do",
"help",
"identify",
"is",
"off",
"on",
"ready",
"render",
"reset",
"safe",
"set"
];
if (window.noticeable) console.warn("Noticeable SDK code snippet loaded more than once");
else {
var n = (window.noticeable = window.noticeable || []);
<!--Use the below code snippet to provide real time updates to the live chat plugin without the need of copying and paste each time to your website when changes are made via PBX-->
<call-us-selector phonesystem-url=https://rometech.east.3cx.us:5001 party="LiveChat528346"></call-us-selector>
<!--Incase you don't want real time updates to the live chat plugin when options are changed, use the below code snippet. Please note that each time you change the settings you will need to copy and paste the snippet code to your website-->
<!--<call-us
phonesystem-url=https://rometech.east.3cx.us:5001
style="position:fixed;font-size:16px;line-height:17px;z-index: 99999;right: 20px; bottom: 20px;"
id="wp-live-chat-by-3CX"
minimized="true"
animation-style="noanimation"
party="LiveChat528346"
minimized-style="bubbleright"
allow-call="true"
allow-video="false"
allow-soundnotifications="true"
enable-mute="true"
enable-onmobile="true"
offline-enabled="true"
enable="true"
ignore-queueownership="false"
authentication="both"
show-operator-actual-name="true"
aknowledge-received="true"
gdpr-enabled="false"
message-userinfo-format="name"
message-dateformat="both"
lang="browser"
button-icon-type="default"
greeting-visibility="none"
greeting-offline-visibility="none"
chat-delay="2000"
enable-direct-call="true"
enable-ga="false"
></call-us>-->
<script defer src=https://downloads-global.3cx.com/downloads/livechatandtalk/v1/callus.js id="tcx-callus-js" charset="utf-8"></script>
<% } %> <% if (env.VITE_APP_INSTANCE === 'PROMANAGER') { %>
<title>ProManager</title>
<meta name="description" content="ProManager" />
<% } %>
<script>
!(function () {
'use strict';
var e = [
'debug',
'destroy',
'do',
'help',
'identify',
'is',
'off',
'on',
'ready',
'render',
'reset',
'safe',
'set',
];
if (window.noticeable) console.warn('Noticeable SDK code snippet loaded more than once');
else {
var n = (window.noticeable = window.noticeable || []);
function t(e) {
return function () {
var t = Array.prototype.slice.call(arguments);
return t.unshift(e), n.push(t), n;
};
}
!(function () {
for (var o = 0; o < e.length; o++) {
var r = e[o];
n[r] = t(r);
function t(e) {
return function () {
var t = Array.prototype.slice.call(arguments);
return t.unshift(e), n.push(t), n;
};
}
})(),
(function () {
var e = document.createElement('script');
(e.async = !0), (e.src = 'https://sdk.noticeable.io/l.js');
var n = document.head;
n.insertBefore(e, n.firstChild);
})();
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="src/index.jsx"></script>
</body>
!(function () {
for (var o = 0; o < e.length; o++) {
var r = e[o];
n[r] = t(r);
}
})(),
(function () {
var e = document.createElement("script");
(e.async = !0), (e.src = "https://sdk.noticeable.io/l.js");
var n = document.head;
n.insertBefore(e, n.firstChild);
})();
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="src/index.jsx"></script>
</body>
</html>

11523
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,108 +8,99 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@ant-design/pro-layout": "^7.19.11",
"@apollo/client": "^3.10.8",
"@emotion/is-prop-valid": "^1.3.0",
"@fingerprintjs/fingerprintjs": "^4.4.3",
"@ant-design/pro-layout": "^7.22.0",
"@apollo/client": "^3.12.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.5.1",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.2.6",
"@sentry/cli": "^2.32.2",
"@reduxjs/toolkit": "^2.5.0",
"@sentry/cli": "^2.40.0",
"@sentry/react": "^7.114.0",
"@splitsoftware/splitio-react": "^1.12.0",
"@tanem/react-nprogress": "^5.0.51",
"@vitejs/plugin-react": "^4.3.1",
"antd": "^5.19.3",
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.53",
"@vitejs/plugin-react": "^4.3.4",
"antd": "^5.23.1",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^3.3.0",
"autosize": "^6.0.1",
"axios": "^1.6.8",
"axios": "^1.7.9",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.12",
"dayjs-business-days2": "^1.2.2",
"dayjs": "^1.11.13",
"dayjs-business-days2": "^1.2.3",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"dotenv": "^16.4.7",
"env-cmd": "^10.1.0",
"exifr": "^7.1.3",
"firebase": "^10.12.4",
"graphql": "^16.9.0",
"i18next": "^23.12.2",
"i18next-browser-languagedetector": "^8.0.0",
"firebase": "^10.13.2",
"graphql": "^16.10.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.2",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.11.4",
"logrocket": "^8.1.1",
"markerjs2": "^2.32.1",
"libphonenumber-js": "^1.11.18",
"logrocket": "^8.1.2",
"markerjs2": "^2.32.3",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"prop-types": "^15.8.1",
"query-string": "^9.0.0",
"query-string": "^9.1.1",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.13.1",
"react-big-calendar": "^1.17.1",
"react-color": "^2.19.3",
"react-cookie": "^7.1.4",
"react-cookie": "^7.2.2",
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^14.1.3",
"react-icons": "^5.2.1",
"react-icons": "^5.4.0",
"react-image-lightbox": "^5.1.4",
"react-joyride": "^2.8.2",
"react-markdown": "^9.0.1",
"react-number-format": "^5.4.0",
"react-markdown": "^9.0.3",
"react-number-format": "^5.4.3",
"react-popopo": "^2.1.9",
"react-product-fruits": "^2.2.6",
"react-redux": "^9.1.2",
"react-product-fruits": "^2.2.61",
"react-redux": "^9.2.0",
"react-resizable": "^3.0.5",
"react-router-dom": "^6.25.1",
"react-router-dom": "^6.26.2",
"react-sticky": "^6.0.3",
"react-virtualized": "^9.22.5",
"react-virtuoso": "^4.7.12",
"recharts": "^2.12.7",
"react-virtuoso": "^4.10.4",
"recharts": "^2.15.0",
"redux": "^5.0.1",
"redux-actions": "^3.0.0",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.77.8",
"socket.io-client": "^4.7.5",
"styled-components": "^6.1.12",
"sass": "^1.83.4",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.14",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"userpilot": "^1.3.2",
"userpilot": "^1.3.6",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^3.5.2"
},
"scripts": {
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "vite",
"build": "dotenvx run --env-file=.env.development.imex -- vite build",
"start:imex": "dotenvx run --env-file=.env.development.imex -- vite",
"start:rome": "dotenvx run --env-file=.env.development.rome -- vite",
"start:promanager": "dotenvx run --env-file=.env.development.promanager -- vite",
"preview:imex": "dotenvx run --env-file=.env.development.imex -- vite preview",
"preview:rome": "dotenvx run --env-file=.env.development.rome -- vite preview",
"build:test:imex": "env-cmd -f .env.test.imex npm run build",
"build:test:rome": "env-cmd -f .env.test.rome npm run build",
"build:test:promanager": "env-cmd -f .env.test.promanager npm run build",
"build:production:imex": "env-cmd -f .env.production.imex npm run build",
"build:production:rome": "env-cmd -f .env.production.rome npm run build",
"build:production:promanager": "env-cmd -f .env.production.promanager npm run build",
"test": "cypress open",
"eject": "react-scripts eject",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js",
"sentry:sourcemaps:imex": "sentry-cli sourcemaps inject --org imex --project imexonline ./build && sentry-cli sourcemaps upload --org imex --project imexonline ./build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"plugin:cypress/recommended"
]
},
"browserslist": {
"production": [
">0.2%",
@@ -129,31 +120,36 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^5.5.2",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.24.7",
"@dotenvx/dotenvx": "^1.6.4",
"@emotion/babel-plugin": "^11.12.0",
"@emotion/react": "^11.12.0",
"@sentry/webpack-plugin": "^2.21.1",
"@babel/preset-react": "^7.26.3",
"@dotenvx/dotenvx": "^1.33.0",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.18.0",
"@sentry/webpack-plugin": "^2.22.4",
"@testing-library/cypress": "^10.0.2",
"browserslist": "^4.23.2",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"cross-env": "^7.0.3",
"cypress": "^13.13.1",
"eslint": "^8.57.0",
"cypress": "^13.17.0",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-cypress": "^2.15.1",
"memfs": "^4.9.3",
"eslint-plugin-react": "^7.37.4",
"globals": "^15.14.0",
"memfs": "^4.17.0",
"os-browserify": "^0.3.0",
"react-error-overlay": "6.0.11",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^5.3.4",
"vite-plugin-babel": "^1.2.0",
"vite": "^6.0.7",
"vite-plugin-babel": "^1.3.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-legacy": "^2.1.0",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-pwa": "^0.20.0",
"vite-plugin-style-import": "^2.0.0"
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-style-import": "^2.0.0",
"workbox-window": "^7.3.0"
}
}

View File

@@ -1,6 +1,6 @@
// Scripts for firebase and firebase messaging
importScripts("https://www.gstatic.com/firebasejs/8.2.0/firebase-app.js");
importScripts("https://www.gstatic.com/firebasejs/8.2.0/firebase-messaging.js");
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js");
// Initialize the Firebase app in the service worker by passing the generated config
let firebaseConfig;
@@ -14,7 +14,7 @@ switch (this.location.hostname) {
storageBucket: "imex-dev.appspot.com",
messagingSenderId: "759548147434",
appId: "1:759548147434:web:e8239868a48ceb36700993",
measurementId: "G-K5XRBVVB4S",
measurementId: "G-K5XRBVVB4S"
};
break;
case "test.imex.online":
@@ -24,7 +24,7 @@ switch (this.location.hostname) {
projectId: "imex-test",
storageBucket: "imex-test.appspot.com",
messagingSenderId: "991923618608",
appId: "1:991923618608:web:633437569cdad78299bef5",
appId: "1:991923618608:web:633437569cdad78299bef5"
// measurementId: "${config.measurementId}",
};
break;
@@ -38,7 +38,7 @@ switch (this.location.hostname) {
storageBucket: "imex-prod.appspot.com",
messagingSenderId: "253497221485",
appId: "1:253497221485:web:3c81c483b94db84b227a64",
measurementId: "G-NTWBKG2L0M",
measurementId: "G-NTWBKG2L0M"
};
}
@@ -49,8 +49,6 @@ const messaging = firebase.messaging();
messaging.onBackgroundMessage(function (payload) {
// Customize notification here
const channel = new BroadcastChannel("imex-sw-messages");
channel.postMessage(payload);
//self.registration.showNotification(notificationTitle, notificationOptions);
console.log("[firebase-messaging-sw.js] Received background message ", payload);
self.registration.showNotification(notificationTitle, notificationOptions);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -2,8 +2,6 @@ import { ApolloProvider } from "@apollo/client";
import { SplitFactoryProvider, SplitSdk } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import dayjs from "../utils/day";
import "dayjs/locale/en";
import React from "react";
import { useTranslation } from "react-i18next";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
@@ -19,8 +17,6 @@ if (import.meta.env.DEV) {
Userpilot.initialize("NX-69145f08");
}
dayjs.locale("en");
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,

View File

@@ -18,10 +18,11 @@ import { checkUserSession } from "../redux/user/user.actions";
import { selectBodyshop, selectCurrentEula, selectCurrentUser } from "../redux/user/user.selectors";
import PrivateRoute from "../components/PrivateRoute";
import "./App.styles.scss";
import handleBeta from "../utils/betaHandler";
import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -96,8 +97,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
LogRocket.init(
InstanceRenderMgr({
imex: "gvfvfw/bodyshopapp",
rome: "rome-online/rome-online",
promanager: "" // TODO: AIO Add in log rocket for promanager instances.
rome: "rome-online/rome-online"
})
);
}
@@ -108,8 +108,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
return <LoadingSpinner message={t("general.labels.loggingin")} />;
}
handleBeta();
if (!online) {
return (
<Result
@@ -136,8 +134,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
<LoadingSpinner
message={InstanceRenderMgr({
imex: t("titles.imexonline"),
rome: t("titles.romeonline"),
promanager: t("titles.promanager")
rome: t("titles.romeonline")
})}
/>
}
@@ -146,84 +143,88 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
currentUser={currentUser}
workspaceCode={InstanceRenderMgr({
imex: null,
rome: "9BkbEseqNqxw8jUH",
promanager: "aoJoEifvezYI0Z0P"
rome: "9BkbEseqNqxw8jUH"
})}
/>
<Routes>
<Route
path="*"
element={
<ErrorBoundary>
<LandingPage />
</ErrorBoundary>
}
/>
<Route
path="/signin"
element={
<ErrorBoundary>
<SignInPage />
</ErrorBoundary>
}
/>
<Route
path="/resetpassword"
element={
<ErrorBoundary>
<ResetPassword />
</ErrorBoundary>
}
/>
<Route
path="/csi/:surveyId"
element={
<ErrorBoundary>
<CsiPage />
</ErrorBoundary>
}
/>
<Route
path="/disclaimer"
element={
<ErrorBoundary>
<DisclaimerPage />
</ErrorBoundary>
}
/>
<Route
path="/mp/:paymentIs"
element={
<ErrorBoundary>
<MobilePaymentContainer />
</ErrorBoundary>
}
/>
<Route
path="/manage/*"
element={
<ErrorBoundary>
<PrivateRoute isAuthorized={currentUser.authorized} />
</ErrorBoundary>
}
>
<Route path="*" element={<ManagePage />} />
</Route>
<Route
path="/tech/*"
element={
<ErrorBoundary>
<PrivateRoute isAuthorized={currentUser.authorized} />
</ErrorBoundary>
}
>
<Route path="*" element={<TechPageContainer />} />
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
</Routes>
<NotificationProvider>
<Routes>
<Route
path="*"
element={
<ErrorBoundary>
<LandingPage />
</ErrorBoundary>
}
/>
<Route
path="/signin"
element={
<ErrorBoundary>
<SignInPage />
</ErrorBoundary>
}
/>
<Route
path="/resetpassword"
element={
<ErrorBoundary>
<ResetPassword />
</ErrorBoundary>
}
/>
<Route
path="/csi/:surveyId"
element={
<ErrorBoundary>
<CsiPage />
</ErrorBoundary>
}
/>
<Route
path="/disclaimer"
element={
<ErrorBoundary>
<DisclaimerPage />
</ErrorBoundary>
}
/>
<Route
path="/mp/:paymentIs"
element={
<ErrorBoundary>
<MobilePaymentContainer />
</ErrorBoundary>
}
/>
<Route
path="/manage/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<ManagePage />} />
</Route>
<Route
path="/tech/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<TechPageContainer />} />
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
</Routes>
</NotificationProvider>
</Suspense>
);
}

View File

@@ -5,6 +5,13 @@
border-bottom: 1px solid #74695c !important;
}
// TODO: This was added because the newest release of ant was making the text color and the background color the same on a selected header
// Tried all available tokens (https://ant.design/components/menu?locale=en-US) and even reverted all our custom styles, to no avail
// This should be kept an eye on, especially if implementing DARK MODE
.ant-menu-submenu-title {
color: rgba(255, 255, 255, 0.65) !important;
}
.imex-table-header {
display: flex;
flex-wrap: wrap;

View File

@@ -26,13 +26,11 @@ const defaultTheme = {
token: {
colorPrimary: InstanceRenderMgr({
imex: "#1890ff",
rome: "#326ade",
promanager: "#1d69a6"
rome: "#326ade"
}),
colorInfo: InstanceRenderMgr({
imex: "#1890ff",
rome: "#326ade",
promanager: "#1d69a6"
rome: "#326ade"
})
}
};

View File

@@ -9,7 +9,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import { pageLimit } from "../../utils/config";
import { exportPageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import PayableExportAll from "../payable-export-all-button/payable-export-all-button.component";
@@ -175,7 +175,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
<Table
loading={loading}
dataSource={dataSource}
pagination={{ position: "top", pageSize: pageLimit }}
pagination={{ position: "top", pageSize: exportPageLimit }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -8,7 +8,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter";
import { pageLimit } from "../../utils/config";
import { exportPageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
@@ -85,6 +85,17 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
sortOrder: state.sortedInfo.columnKey === "amount" && state.sortedInfo.order,
render: (text, record) => <CurrencyFormatter>{record.amount}</CurrencyFormatter>
},
{
title: t("payments.fields.type"),
dataIndex: "type",
key: "type",
sorter: (a, b) => a.type.localeCompare(b.type),
sortOrder: state.sortedInfo.columnKey === "type" && state.sortedInfo.order,
filters: bodyshop.md_payment_types.map((s) => {
return { text: s, value: [s] };
}),
onFilter: (value, record) => value.includes(record.type)
},
{
title: t("payments.fields.memo"),
dataIndex: "memo",
@@ -177,7 +188,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
<Table
loading={loading}
dataSource={dataSource}
pagination={{ position: "top", pageSize: pageLimit }}
pagination={{ position: "top", pageSize: exportPageLimit }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -1,18 +1,19 @@
import { Button, Card, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { exportPageLimit } from "../../utils/config";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { DateFormatter } from "../../utils/DateFormatter";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import JobMarkSelectedExported from "../jobs-mark-selected-exported/jobs-mark-selected-exported";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
@@ -170,13 +171,22 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
extra={
<Space wrap>
{!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && (
<JobsExportAllButton
jobIds={selectedJobs}
disabled={transInProgress || selectedJobs.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedJobs}
refetch={refetch}
/>
<>
<JobMarkSelectedExported
jobIds={selectedJobs}
disabled={transInProgress || selectedJobs.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedJobs}
refetch={refetch}
/>
<JobsExportAllButton
jobIds={selectedJobs}
disabled={transInProgress || selectedJobs.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedJobs}
refetch={refetch}
/>
</>
)}
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
<Input.Search
@@ -191,7 +201,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
<Table
loading={loading}
dataSource={dataSource}
pagination={{ position: "top" }}
pagination={{ position: "top", pageSize: exportPageLimit }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -3,7 +3,7 @@ import AllocationsAssignmentComponent from "./allocations-assignment.component";
import { useMutation } from "@apollo/client";
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries";
import { useTranslation } from "react-i18next";
import { notification } from "antd";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function AllocationsAssignmentContainer({ jobLineId, hours, refetch }) {
const visibilityState = useState(false);
@@ -14,6 +14,7 @@ export default function AllocationsAssignmentContainer({ jobLineId, hours, refet
employeeid: null
});
const [insertAllocation] = useMutation(INSERT_ALLOCATION);
const notification = useNotification();
const handleAssignment = () => {
insertAllocation({ variables: { alloc: { ...assignment } } })

View File

@@ -3,7 +3,7 @@ import AllocationsBulkAssignment from "./allocations-bulk-assignment.component";
import { useMutation } from "@apollo/client";
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries";
import { useTranslation } from "react-i18next";
import { notification } from "antd";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function AllocationsBulkAssignmentContainer({ jobLines, refetch }) {
const visibilityState = useState(false);
@@ -12,6 +12,7 @@ export default function AllocationsBulkAssignmentContainer({ jobLines, refetch }
employeeid: null
});
const [insertAllocation] = useMutation(INSERT_ALLOCATION);
const notification = useNotification();
const handleAssignment = () => {
const allocs = jobLines.reduce((acc, value) => {

View File

@@ -2,12 +2,13 @@ import React from "react";
import { useMutation } from "@apollo/client";
import { DELETE_ALLOCATION } from "../../graphql/allocations.queries";
import AllocationsLabelComponent from "./allocations-employee-label.component";
import { notification } from "antd";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function AllocationsLabelContainer({ allocation, refetch }) {
const [deleteAllocation] = useMutation(DELETE_ALLOCATION);
const { t } = useTranslation();
const notification = useNotification();
const handleClick = (e) => {
e.preventDefault();

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, notification, Popconfirm } from "antd";
import { Button, Popconfirm } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { DELETE_BILL } from "../../graphql/bills.queries";
@@ -9,6 +9,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({
@@ -21,6 +22,7 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [deleteBill] = useMutation(DELETE_BILL);
const notification = useNotification();
const handleDelete = async () => {
setLoading(true);

View File

@@ -98,7 +98,7 @@ export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail
});
billlines.forEach((billline) => {
const { deductedfromlbr, inventories, jobline, ...il } = billline;
const { deductedfromlbr, inventories, jobline, original_actual_price, create_ppc, ...il } = billline;
delete il.__typename;
if (il.id) {

View File

@@ -1,6 +1,6 @@
import { useApolloClient, useMutation } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, notification, Space } from "antd";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -24,6 +24,7 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -49,6 +50,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const [loading, setLoading] = useState(false);
const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
const notification = useNotification();
const {
treatments: { Enhanced_Payroll }
@@ -291,7 +293,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
jobid: values.jobid,
invoice_number: remainingValues.invoice_number,
vendorid: remainingValues.vendorid
}
},
notification
});
});
} else {
@@ -305,7 +308,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
billId: billId,
tagsArray: null,
callback: null
}
},
notification
);
});
}
@@ -325,7 +329,9 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
},
{},
"p"
"p",
null,
notification
);
}

View File

@@ -14,7 +14,6 @@ import dayjs from "../../utils/day";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AlertComponent from "../alert/alert.component";
import BillFormLinesExtended from "../bill-form-lines-extended/bill-form-lines-extended.component";
import FormDatePicker from "../form-date-picker/form-date-picker.component";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobSearchSelect from "../job-search-select/job-search-select.component";
@@ -22,6 +21,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import BillFormLines from "./bill-form.lines.component";
import { CalculateBillTotal } from "./bill-form.totals.utility";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -276,7 +276,7 @@ export function BillFormComponent({
})
]}
>
<FormDatePicker disabled={disabled} />
<DateTimePicker isDateOnly disabled={disabled} />
</Form.Item>
<Form.Item
label={t("bills.fields.is_credit_memo")}

View File

@@ -7,10 +7,10 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CiecaSelect from "../../utils/Ciecaselect";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -72,7 +72,14 @@ export function BillEnterModalLinesComponent({
<BillLineSearchSelect
disabled={disabled}
options={lineData}
style={{ width: "100%", minWidth: "10rem" }}
style={{
width: "20rem",
maxWidth: "20rem",
minWidth: "10rem",
whiteSpace: "normal",
height: "auto",
minHeight: "32px" // default height of Ant Design inputs
}}
allowRemoved={form.getFieldValue("is_credit_memo") || false}
onSelect={(value, opt) => {
setFieldsValue({
@@ -105,7 +112,7 @@ export function BillEnterModalLinesComponent({
title: t("billlines.fields.line_desc"),
dataIndex: "line_desc",
editable: true,
width: "20rem",
formItemProps: (field) => {
return {
key: `${field.index}line_desc`,
@@ -119,7 +126,7 @@ export function BillEnterModalLinesComponent({
]
};
},
formInput: (record, index) => <Input disabled={disabled} />
formInput: (record, index) => <Input.TextArea disabled={disabled} autoSize />
},
{
title: t("billlines.fields.quantity"),
@@ -221,7 +228,6 @@ export function BillEnterModalLinesComponent({
}}
</Form.Item>
)
//Do not need to set for promanager as it will default to Rome.
})
},
{
@@ -455,7 +461,6 @@ export function BillEnterModalLinesComponent({
...InstanceRenderManager({
rome: [],
promanager: [],
imex: [
{
title: t("billlines.fields.federal_tax_applicable"),
@@ -468,8 +473,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
initialValue: InstanceRenderManager({
imex: true,
rome: false,
promanager: false
rome: false
}),
name: [field.name, "applicable_taxes", "federal"]
};
@@ -496,7 +500,6 @@ export function BillEnterModalLinesComponent({
...InstanceRenderManager({
rome: [],
promanager: [],
imex: [
{
title: t("billlines.fields.local_tax_applicable"),
@@ -622,11 +625,15 @@ const EditableCell = ({
wrapper,
...restProps
}) => {
const propsFinal = formItemProps && formItemProps(record);
if (propsFinal && "key" in propsFinal) {
delete propsFinal.key;
}
if (additional)
return (
<td {...restProps}>
<div size="small">
<Form.Item name={dataIndex} labelCol={{ span: 0 }} {...(formItemProps && formItemProps(record))}>
<Form.Item name={dataIndex} labelCol={{ span: 0 }} {...propsFinal}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
{additional && additional(record, record.name)}
@@ -637,7 +644,7 @@ const EditableCell = ({
return (
<wrapper>
<td {...restProps}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...(formItemProps && formItemProps(record))}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
</td>
@@ -645,7 +652,7 @@ const EditableCell = ({
);
return (
<td {...restProps}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...(formItemProps && formItemProps(record))}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
</td>

View File

@@ -11,7 +11,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
disabled={disabled}
ref={ref}
showSearch
popupMatchSelectWidth={false}
popupMatchSelectWidth={true}
optionLabelProp={"name"}
// optionFilterProp="line_desc"
filterOption={(inputValue, option) => {
@@ -43,7 +43,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
label: (
<>
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
<span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : ""
@@ -57,7 +57,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
<span style={{ float: "right", paddingleft: "1rem" }}>
{item.act_price ? `$${item.act_price && item.act_price.toFixed(2)}` : ``}
</span>
</>
</div>
)
}))
]}

View File

@@ -1,5 +1,5 @@
import { gql, useMutation } from "@apollo/client";
import { Button, notification } from "antd";
import { Button } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -8,6 +8,7 @@ import { createStructuredSelector } from "reselect";
import { selectAuthLevel, selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -24,6 +25,7 @@ export function BillMarkExportedButton({ currentUser, bodyshop, authLevel, bill
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [insertExportLog] = useMutation(INSERT_EXPORT_LOG);
const notification = useNotification();
const [updateBill] = useMutation(gql`
mutation UPDATE_BILL($billId: uuid!) {

View File

@@ -3,11 +3,13 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function BillPrintButton({ billid }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const Templates = TemplateList("job_special");
const notification = useNotification();
const submitHandler = async () => {
setLoading(true);
@@ -20,7 +22,9 @@ export default function BillPrintButton({ billid }) {
}
},
{},
"p"
"p",
null,
notification
);
} catch (e) {
console.warn("Warning: Error generating a document.");

View File

@@ -1,5 +1,5 @@
import { gql, useMutation } from "@apollo/client";
import { Button, notification } from "antd";
import { Button } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -7,6 +7,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -21,6 +22,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportB
export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const notification = useNotification();
const [updateBill] = useMutation(gql`
mutation UPDATE_BILL($billId: uuid!) {

View File

@@ -1,6 +1,6 @@
import { FileAddFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, notification, Tooltip } from "antd";
import { Button, Tooltip } from "antd";
import { t } from "i18next";
import dayjs from "./../../utils/day";
import React, { useState } from "react";
@@ -11,6 +11,7 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import queryString from "query-string";
import { useLocation } from "react-router-dom";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -25,6 +26,7 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
const [loading, setLoading] = useState(false);
const { billid } = queryString.parse(useLocation().search);
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const notification = useNotification();
const addToInventory = async () => {
setLoading(true);

View File

@@ -2,6 +2,7 @@ import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -13,8 +14,10 @@ import { alphaSort, dateSort } from "../../utils/sorters";
import { TemplateList } from "../../utils/TemplateConstants";
import BillDeleteButton from "../bill-delete-button/bill-delete-button.component";
import BillDetailEditReturnComponent from "../bill-detail-edit/bill-detail-edit-return.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import { FaTasks } from "react-icons/fa";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -170,6 +173,8 @@ export function BillsListTableComponent({
)
: [];
const hasBillsAccess = HasFeatureAccess({ bodyshop, featureName: "bills" });
return (
<Card
title={t("bills.labels.bills")}
@@ -181,6 +186,7 @@ export function BillsListTableComponent({
{job && job.converted ? (
<>
<Button
disabled={!hasBillsAccess}
onClick={() => {
setBillEnterContext({
actions: { refetch: billsQuery.refetch },
@@ -190,9 +196,10 @@ export function BillsListTableComponent({
});
}}
>
{t("jobs.actions.postbills")}
<LockerWrapperComponent featureName="bills">{t("jobs.actions.postbills")}</LockerWrapperComponent>
</Button>
<Button
disabled={!hasBillsAccess}
onClick={() => {
setReconciliationContext({
actions: { refetch: billsQuery.refetch },
@@ -203,7 +210,7 @@ export function BillsListTableComponent({
});
}}
>
{t("jobs.actions.reconcile")}
<LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent>
</Button>
</>
) : null}
@@ -211,6 +218,7 @@ export function BillsListTableComponent({
<Input.Search
placeholder={t("general.labels.search")}
value={searchText}
disabled={!hasBillsAccess}
onChange={(e) => {
e.preventDefault();
setSearchText(e.target.value);
@@ -226,8 +234,13 @@ export function BillsListTableComponent({
}}
columns={columns}
rowKey="id"
dataSource={filteredBills}
dataSource={hasBillsAccess ? filteredBills : []}
onChange={handleTableChange}
locale={{
...(!hasBillsAccess && {
emptyText: <UpsellComponent upsell={upsellEnum().bills.general} />
})
}}
/>
</Card>
);

View File

@@ -9,6 +9,7 @@ import { selectCaBcEtfTableConvert } from "../../redux/modals/modals.selectors";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import CaBcEtfTableModalComponent from "./ca-bc-etf-table.modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
caBcEtfTableModal: selectCaBcEtfTableConvert
@@ -25,6 +26,7 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const EtfTemplate = TemplateList("special").ca_bc_etf_table;
const notification = useNotification();
const handleFinish = async (values) => {
logImEXEvent("ca_bc_etf_table_parse");
@@ -53,7 +55,9 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
}
},
{},
values.sendby === "email" ? "e" : "p"
values.sendby === "email" ? "e" : "p",
null,
notification
);
setLoading(false);
};

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { CopyFilled, DeleteFilled } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Card, Col, Form, Input, Row, Space, Spin, Statistic, notification } from "antd";
import { Button, Card, Col, Form, Input, message, Row, Space, Spin, Statistic } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -14,51 +14,75 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
import { getCurrentUser } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment,
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
currentUser: getCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
),
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
});
const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisible, insertAuditTrail }) => {
const CardPaymentModalComponent = ({
bodyshop,
currentUser,
cardPaymentModal,
toggleModalVisible,
insertAuditTrail
}) => {
const { context, actions } = cardPaymentModal;
const [form] = Form.useForm();
const [paymentLink, setPaymentLink] = useState();
const [loading, setLoading] = useState(false);
// const [insertPayment] = useMutation(INSERT_NEW_PAYMENT);
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
const { t } = useTranslation();
const notification = useNotification();
const [, { data, refetch, queryLoading }] = useLazyQuery(QUERY_RO_AND_OWNER_BY_JOB_PKS, {
variables: { jobids: [context.jobid] },
skip: true
skip: !context?.jobid
});
//Initialize the intellipay window.
const collectIPayFields = () => {
const iPayFields = document.querySelectorAll(".ipayfield");
const iPayData = {};
iPayFields.forEach((field) => {
iPayData[field.dataset.ipayname] = field.value;
});
return iPayData;
};
const SetIntellipayCallbackFunctions = () => {
console.log("*** Set IntelliPay callback functions.");
window.intellipay.runOnClose(() => {
//window.intellipay.initialize();
});
window.intellipay.runOnApproval(async function (response) {
window.intellipay.runOnApproval(() => {
//2024-04-25: Nothing is going to happen here anymore. We'll completely rely on the callback.
//Add a slight delay to allow the refetch to properly get the data.
setTimeout(() => {
if (actions && actions.refetch && typeof actions.refetch === "function")
actions.refetch();
if (actions?.refetch) actions.refetch();
setLoading(false);
toggleModalVisible();
}, 750);
});
window.intellipay.runOnNonApproval(async function (response) {
window.intellipay.runOnNonApproval(async (response) => {
// Mutate unsuccessful payment
const { payments } = form.getFieldsValue();
@@ -86,36 +110,44 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
});
};
const handleIntelliPayCharge = async () => {
setLoading(true);
//Validate
try {
await form.validateFields();
} catch (error) {
} catch {
setLoading(false);
return;
}
const iPayData = collectIPayFields();
const { payments } = form.getFieldsValue();
try {
const response = await axios.post("/intellipay/lightbox_credentials", {
bodyshop,
refresh: !!window.intellipay,
paymentSplitMeta: form.getFieldsValue(),
iPayData: iPayData,
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email }))
});
if (window.intellipay) {
// eslint-disable-next-line no-eval
eval(response.data);
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
pollForIntelliPay(() => {
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
});
} else {
var rg = document.createRange();
let node = rg.createContextualFragment(response.data);
const rg = document.createRange();
const node = rg.createContextualFragment(response.data);
document.documentElement.appendChild(node);
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
window.intellipay.initialize();
pollForIntelliPay(() => {
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
window.intellipay.initialize();
});
}
} catch (error) {
notification.open({
@@ -126,6 +158,44 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
}
};
const handleIntelliPayChargeShortLink = async () => {
setLoading(true);
//Validate
try {
await form.validateFields();
} catch {
setLoading(false);
return;
}
const iPayData = collectIPayFields();
try {
const { payments } = form.getFieldsValue();
const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop,
amount: payments.reduce((acc, val) => acc + (val?.amount || 0), 0),
account: payments && data?.jobs?.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null,
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email })),
paymentSplitMeta: form.getFieldsValue(),
iPayData: iPayData
});
if (response?.data?.shorUrl) {
setPaymentLink(response.data.shorUrl);
await navigator.clipboard.writeText(response.data.shorUrl);
message.success(t("general.actions.copied"));
}
setLoading(false);
} catch (error) {
notification.open({
type: "error",
message: t("job_payments.notifications.error.openingip")
});
setLoading(false);
}
};
return (
<Card title="Card Payment">
<Spin spinning={loading}>
@@ -137,81 +207,56 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
}}
>
<Form.List name={["payments"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<JobSearchSelectComponent notExported={false} clm_no />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
key={`${index}amount`}
label={t("payments.fields.amount")}
name={[field.name, "amount"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyFormItemComponent />
</Form.Item>
</Col>
<Col span={2}>
<DeleteFilled
style={{ margin: "1rem" }}
onClick={() => {
remove(field.name);
}}
/>
</Col>
</Row>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{ width: "100%" }}
>
{t("general.actions.add")}
</Button>
{(fields, { add, remove }) => (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[{ required: true }]}
>
<JobSearchSelectComponent notExported={false} clm_no />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
key={`${index}amount`}
label={t("payments.fields.amount")}
name={[field.name, "amount"]}
rules={[{ required: true }]}
>
<CurrencyFormItemComponent />
</Form.Item>
</Col>
<Col span={2}>
<DeleteFilled style={{ margin: "1rem" }} onClick={() => remove(field.name)} />
</Col>
</Row>
</Form.Item>
</div>
);
}}
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} style={{ width: "100%" }}>
{t("general.actions.add")}
</Button>
</Form.Item>
</div>
)}
</Form.List>
<Form.Item
shouldUpdate={(prevValues, curValues) =>
prevValues.payments?.map((p) => p?.jobid).join() !== curValues.payments?.map((p) => p?.jobid).join()
prevValues.payments?.map((p) => p?.jobid + p?.amount).join() !==
curValues.payments?.map((p) => p?.jobid + p?.amount).join()
}
>
{() => {
//If all of the job ids have been fileld in, then query and update the IP field.
const { payments } = form.getFieldsValue();
if (
payments?.length > 0 &&
payments?.filter((p) => p?.jobid).length === payments?.length
) {
if (payments?.length > 0 && payments?.filter((p) => p?.jobid).length === payments?.length) {
refetch({ jobids: payments.map((p) => p.jobid) });
}
return (
@@ -243,10 +288,7 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
>
{() => {
const { payments } = form.getFieldsValue();
const totalAmountToCharge = payments?.reduce((acc, val) => {
return acc + (val?.amount || 0);
}, 0);
const totalAmountToCharge = payments?.reduce((acc, val) => acc + (val?.amount || 0), 0);
return (
<Space style={{ float: "right" }}>
<Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} />
@@ -273,14 +315,63 @@ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisi
>
{t("job_payments.buttons.proceedtopayment")}
</Button>
<Space direction="vertical" align="center">
<Button
type="primary"
// data-ipayname="submit"
className="ipayfield"
loading={queryLoading || loading}
disabled={!(totalAmountToCharge > 0)}
onClick={handleIntelliPayChargeShortLink}
>
{t("job_payments.buttons.create_short_link")}
</Button>
</Space>
</Space>
);
}}
</Form.Item>
</Form>
{paymentLink && (
<Space
style={{ cursor: "pointer", float: "right" }}
align="end"
onClick={() => {
navigator.clipboard.writeText(paymentLink);
message.success(t("general.actions.copied"));
}}
>
<div>{paymentLink}</div>
<CopyFilled />
</Space>
)}
</Spin>
</Card>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(CardPaymentModalComponent);
//Poll for window.IntelliPay.fixAmount for 5 seconds. If it doesn't come up, just try anyways to force the possible error.
function pollForIntelliPay(callbackFunction) {
const timeout = 5000;
const interval = 150; // Poll every 100 milliseconds
const startTime = Date.now();
function checkFixAmount() {
if (window.intellipay && window.intellipay.fixAmount !== undefined) {
callbackFunction();
return;
}
if (Date.now() - startTime >= timeout) {
console.log("Stopped polling IntelliPay after 10 seconds. Attemping to set functions anyways.");
callbackFunction();
return;
}
setTimeout(checkFixAmount, interval);
}
checkFixAmount();
}

View File

@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectCardPayment } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CardPaymentModalComponent from "./card-payment-modal.component.";
import CardPaymentModalComponent from "./card-payment-modal.component";
const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment,

View File

@@ -1,89 +1,50 @@
import { useApolloClient } from "@apollo/client";
import { getToken, onMessage } from "@firebase/messaging";
import { Button, notification, Space } from "antd";
import { getToken } from "@firebase/messaging";
import axios from "axios";
import React, { useEffect } from "react";
import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import FcmHandler from "../../utils/fcm-handler";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();
const client = useApolloClient();
const { socket } = useContext(SocketContext);
useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;
async function SubscribeToTopic() {
async function SubscribeToTopicForFCMNotification() {
try {
const r = await axios.post("/notifications/subscribe", {
await requestForToken();
await axios.post("/notifications/subscribe", {
fcm_tokens: await getToken(messaging, {
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
}),
type: "messaging",
imexshopid: bodyshop.imexshopid
});
console.log("FCM Topic Subscription", r.data);
} catch (error) {
console.log("Error attempting to subscribe to messaging topic: ", error);
notification.open({
key: "fcm",
type: "warning",
message: t("general.errors.fcm"),
btn: (
<Space>
<Button
onClick={async () => {
await requestForToken();
SubscribeToTopic();
}}
>
{t("general.actions.tryagain")}
</Button>
<Button
onClick={() => {
const win = window.open(
"https://help.imex.online/en/article/enabling-notifications-o978xi/",
"_blank"
);
win.focus();
}}
>
{t("general.labels.help")}
</Button>
</Space>
)
});
}
}
SubscribeToTopic();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bodyshop]);
SubscribeToTopicForFCMNotification();
useEffect(() => {
function handleMessage(payload) {
FcmHandler({
client,
payload: (payload && payload.data && payload.data.data) || payload.data
});
//Register WS handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
}
let stopMessageListener, channel;
try {
stopMessageListener = onMessage(messaging, handleMessage);
channel = new BroadcastChannel("imex-sw-messages");
channel.addEventListener("message", handleMessage);
} catch (error) {
console.log("Unable to set event listeners.");
}
return () => {
stopMessageListener && stopMessageListener();
channel && channel.removeEventListener("message", handleMessage);
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket });
}
};
}, [client]);
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -0,0 +1,434 @@
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { gql } from "@apollo/client";
const logLocal = (message, ...args) => {
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
console.log(`==================== ${message} ====================`);
console.dir({ ...args });
}
};
// Utility function to enrich conversation data
const enrichConversation = (conversation, isOutbound) => ({
...conversation,
updated_at: conversation.updated_at || new Date().toISOString(),
unreadcnt: conversation.unreadcnt || 0,
archived: conversation.archived || false,
label: conversation.label || null,
job_conversations: conversation.job_conversations || [],
messages_aggregate: conversation.messages_aggregate || {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: isOutbound ? 0 : 1
}
},
__typename: "conversations"
});
export const registerMessagingHandlers = ({ socket, client }) => {
if (!(socket && client)) return;
const handleNewMessageSummary = async (message) => {
const { conversationId, newConversation, existingConversation, isoutbound } = message;
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
const queryVariables = { offset: 0 };
if (!existingConversation && conversationId) {
// Attempt to read from the cache to determine if this is actually a new conversation
try {
const cachedConversation = client.cache.readFragment({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fragment: gql`
fragment ExistingConversationCheck on conversations {
id
}
`
});
if (cachedConversation) {
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", {
conversationId
});
return handleNewMessageSummary({
...message,
existingConversation: true
});
}
} catch (error) {
logLocal("handleNewMessageSummary - Cache miss", { conversationId });
}
}
// Handle new conversation
if (!existingConversation && newConversation?.phone_num) {
logLocal("handleNewMessageSummary - New Conversation", newConversation);
try {
const queryResults = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: queryVariables
});
const existingConversations = queryResults?.conversations || [];
const enrichedConversation = enrichConversation(newConversation, isoutbound);
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existingConversations = []) {
return [enrichedConversation, ...existingConversations];
}
}
});
}
} catch (error) {
console.error("Error updating cache for new conversation:", error);
}
return;
}
// Handle existing conversation
if (existingConversation) {
try {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
updated_at: () => new Date().toISOString(),
archived: () => false,
messages_aggregate(cached = { aggregate: { count: 0 } }) {
const currentCount = cached.aggregate?.count || 0;
if (!isoutbound) {
return {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: currentCount + 1
}
};
}
return cached;
}
}
});
} catch (error) {
console.error("Error updating cache for existing conversation:", error);
}
return;
}
logLocal("New Conversation Summary finished without work", { message });
};
const handleNewMessageDetailed = (message) => {
const { conversationId, newMessage } = message;
logLocal("handleNewMessageDetailed - Start", message);
try {
// Check if the conversation exists in the cache
const queryResults = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
if (!queryResults?.conversations_by_pk) {
console.warn("Conversation not found in cache:", { conversationId });
return;
}
// Append the new message to the conversation's message list using cache.modify
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages(existingMessages = []) {
return [...existingMessages, newMessage];
}
}
});
logLocal("handleNewMessageDetailed - Message appended successfully", {
conversationId,
newMessage
});
} catch (error) {
console.error("Error updating conversation messages in cache:", error);
}
};
const handleMessageChanged = (message) => {
if (!message) {
logLocal("handleMessageChanged - No message provided", message);
return;
}
logLocal("handleMessageChanged - Start", message);
try {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: message.conversationid }),
fields: {
messages(existingMessages = [], { readField }) {
return existingMessages.map((messageRef) => {
// Check if this is the message to update
if (readField("id", messageRef) === message.id) {
const currentStatus = readField("status", messageRef);
// Handle known types of message changes
switch (message.type) {
case "status-changed":
// Prevent overwriting if the current status is already "delivered"
if (currentStatus === "delivered") {
logLocal("handleMessageChanged - Status already delivered, skipping update", {
messageId: message.id
});
return messageRef;
}
// Update the status field
return {
...messageRef,
status: message.status
};
case "text-updated":
// Handle changes to the message text
return {
...messageRef,
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
return messageRef;
}
}
return messageRef; // Keep other messages unchanged
});
}
}
});
logLocal("handleMessageChanged - Message updated successfully", {
messageId: message.id,
type: message.type
});
} catch (error) {
console.error("handleMessageChanged - Error modifying cache:", error);
}
};
const handleConversationChanged = async (data) => {
if (!data) {
logLocal("handleConversationChanged - No data provided", data);
return;
}
const { conversationId, type, job_conversations, messageIds, ...fields } = data;
logLocal("handleConversationChanged - Start", data);
const updatedAt = new Date().toISOString();
const updateConversationList = (newConversation) => {
try {
const existingList = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
const updatedList = existingList?.conversations
? [
newConversation,
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 },
data: {
conversations: updatedList
}
});
logLocal("handleConversationChanged - Conversation list updated successfully", newConversation);
} catch (error) {
console.error("Error updating conversation list in the cache:", error);
}
};
// Handle specific types
try {
switch (type) {
case "conversation-marked-read":
if (conversationId && messageIds?.length > 0) {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages(existingMessages = [], { readField }) {
return existingMessages.map((message) => {
if (messageIds.includes(readField("id", message))) {
return { ...message, read: true };
}
return message;
});
},
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
})
}
});
}
break;
case "conversation-created":
updateConversationList({ ...fields, job_conversations, updated_at: updatedAt });
break;
case "conversation-unarchived":
case "conversation-archived":
// Would like to someday figure out how to get this working without refetch queries,
// But I have but a solid 4 hours into it, and there are just too many weird occurrences
try {
const listQueryVariables = { offset: 0 };
const detailsQueryVariables = { conversationId };
// Check if conversation details exist in the cache
const detailsExist = !!client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
});
// Refetch conversation list
await client.refetchQueries({
include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])],
variables: [
{ query: CONVERSATION_LIST_QUERY, variables: listQueryVariables },
...(detailsExist
? [
{
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
}
]
: [])
]
});
logLocal("handleConversationChanged - Refetched queries after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change:", error);
}
break;
case "tag-added":
// Ensure `job_conversations` is properly formatted
const formattedJobConversations = job_conversations.map((jc) => ({
__typename: "job_conversations",
jobid: jc.jobid || jc.job?.id,
conversationid: conversationId,
job: jc.job || {
__typename: "jobs",
id: data.selectedJob.id,
ro_number: data.selectedJob.ro_number,
ownr_co_nm: data.selectedJob.ownr_co_nm,
ownr_fn: data.selectedJob.ownr_fn,
ownr_ln: data.selectedJob.ownr_ln
}
}));
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
job_conversations: (existing = []) => {
// Ensure no duplicates based on both `conversationid` and `jobid`
const existingLinks = new Set(
existing.map((jc) => {
const jobId = client.cache.readFragment({
id: client.cache.identify(jc),
fragment: gql`
fragment JobConversationLinkAdded on job_conversations {
jobid
conversationid
}
`
})?.jobid;
return `${jobId}:${conversationId}`; // Unique identifier for a job-conversation link
})
);
const newItems = formattedJobConversations.filter((jc) => {
const uniqueLink = `${jc.jobid}:${jc.conversationid}`;
return !existingLinks.has(uniqueLink);
});
return [...existing, ...newItems];
}
}
});
break;
case "tag-removed":
try {
const conversationCacheId = client.cache.identify({ __typename: "conversations", id: conversationId });
// Evict the specific cache entry for job_conversations
client.cache.evict({
id: conversationCacheId,
fieldName: "job_conversations"
});
// Garbage collect evicted entries
client.cache.gc();
logLocal("handleConversationChanged - tag removed - Refetched conversation list after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change: (Tag Removed)", error);
}
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
...Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
)
}
});
}
} catch (error) {
console.error("Error handling conversation changes:", { type, error });
}
};
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
};
export const unregisterMessagingHandlers = ({ socket }) => {
if (!socket) return;
socket.off("new-message-summary");
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("conversation-changed");
};

View File

@@ -1,27 +1,49 @@
import { useMutation } from "@apollo/client";
import { Button } from "antd";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatArchiveButton({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatArchiveButton({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const { socket } = useContext(SocketContext);
const handleToggleArchive = async () => {
setLoading(true);
await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived },
refetchQueries: ["CONVERSATION_LIST_QUERY"]
const updatedConversation = await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived }
});
if (socket) {
socket.emit("conversation-modified", {
type: "conversation-archived",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
archived: updatedConversation.data.update_conversations_by_pk.archived
});
}
setLoading(false);
};
return (
<Button onClick={handleToggleArchive} loading={loading} type="primary">
<Button onClick={handleToggleArchive} loading={loading} className="archive-button" type="primary">
{conversation.archived ? t("messaging.labels.unarchive") : t("messaging.labels.archive")}
</Button>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatArchiveButton);

View File

@@ -1,7 +1,7 @@
import { Badge, Card, List, Space, Tag } from "antd";
import React from "react";
import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List as VirtualizedList } from "react-virtualized";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
import { setSelectedConversation } from "../../redux/messaging/messaging.actions";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
@@ -19,19 +19,26 @@ const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
});
function ChatConversationListComponent({
conversationList,
selectedConversation,
setSelectedConversation,
loadMoreConversations
}) {
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 60
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
const [, forceUpdate] = useState(false);
const rowRenderer = ({ index, key, style, parent }) => {
const item = conversationList[index];
// Re-render every minute
useEffect(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
}, []);
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index) => {
const item = sortedConversationList[index];
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
item.job_conversations.length > 0
@@ -52,7 +59,8 @@ function ChatConversationListComponent({
)}
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count || 0} />;
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const getCardStyle = () =>
item.id === selectedConversation
@@ -60,40 +68,26 @@ function ChatConversationListComponent({
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" };
return (
<CellMeasurer key={key} cache={cache} parent={parent} columnIndex={0} rowIndex={index}>
<List.Item
onClick={() => setSelectedConversation(item.id)}
style={style}
className={`chat-list-item
${item.id === selectedConversation ? "chat-list-selected-conversation" : null}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
</CellMeasurer>
<List.Item
key={item.id}
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
);
};
return (
<div className="chat-list-container">
<AutoSizer>
{({ height, width }) => (
<VirtualizedList
height={height}
width={width}
rowCount={conversationList.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
if (scrollTop + clientHeight === scrollHeight) {
loadMoreConversations();
}
}}
/>
)}
</AutoSizer>
<Virtuoso
data={sortedConversationList}
itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }}
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
.chat-list-container {
overflow: hidden;
height: 100%;
height: 100%; /* Ensure it takes up the full available height */
border: 1px solid gainsboro;
overflow: auto; /* Allow scrolling for the Virtuoso component */
}
.chat-list-item {
@@ -14,3 +14,24 @@
color: #ff7a00;
}
}
/* Virtuoso item container adjustments */
.chat-list-container > div {
height: 100%; /* Ensure Virtuoso takes full height */
display: flex;
flex-direction: column;
}
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* Subtle shadow for better definition */
}
&:hover .ant-card {
border-color: #ff7a00; /* Highlight border on hover */
}
}

View File

@@ -1,18 +1,29 @@
import { useMutation } from "@apollo/client";
import { Tag } from "antd";
import React from "react";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatConversationTitleTags({ jobConversations }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const { socket } = useContext(SocketContext);
const handleRemoveTag = (jobId) => {
const handleRemoveTag = async (jobId) => {
const convId = jobConversations[0].conversationid;
if (!!convId) {
removeJobConversation({
await removeJobConversation({
variables: {
conversationId: convId,
jobId: jobId
@@ -28,6 +39,17 @@ export default function ChatConversationTitleTags({ jobConversations }) {
});
}
});
if (socket) {
// Emit the `conversation-modified` event
socket.emit("conversation-modified", {
bodyshopId: bodyshop.id,
conversationId: convId,
type: "tag-removed",
jobId: jobId
});
}
logImEXEvent("messaging_remove_job_tag", {
conversationId: convId,
jobId: jobId
@@ -54,3 +76,5 @@ export default function ChatConversationTitleTags({ jobConversations }) {
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitleTags);

View File

@@ -6,10 +6,16 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv
import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import { createStructuredSelector } from "reselect";
import { connect } from "react-redux";
export default function ChatConversationTitle({ conversation }) {
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = () => ({});
export function ChatConversationTitle({ conversation }) {
return (
<Space wrap>
<Space className="chat-title" wrap>
<PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter>
<ChatLabelComponent conversation={conversation} />
<ChatPrintButton conversation={conversation} />
@@ -19,3 +25,5 @@ export default function ChatConversationTitle({ conversation }) {
</Space>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle);

View File

@@ -5,10 +5,26 @@ import ChatMessageListComponent from "../chat-messages-list/chat-message-list.co
import ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import "./chat-conversation.styles.scss";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatConversationComponent({ subState, conversation, messages, handleMarkConversationAsRead }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatConversationComponent({
subState,
conversation,
messages,
handleMarkConversationAsRead,
bodyshop
}) {
const [loading, error] = subState;
if (conversation?.archived) return null;
if (loading) return <LoadingSkeleton />;
if (error) return <AlertComponent message={error.message} type="error" />;
@@ -18,9 +34,11 @@ export default function ChatConversationComponent({ subState, conversation, mess
onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead}
>
<ChatConversationTitle conversation={conversation} />
<ChatConversationTitle conversation={conversation} bodyshop={bodyshop} />
<ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} />
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationComponent);

View File

@@ -1,22 +1,25 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client";
import React, { useState } from "react";
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import axios from "axios";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
export default connect(mapStateToProps, null)(ChatConversationContainer);
function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useContext(SocketContext);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
export function ChatConversationContainer({ bodyshop, selectedConversation }) {
// Fetch conversation details
const {
loading: convoLoading,
error: convoError,
@@ -27,55 +30,145 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
nextFetchPolicy: "network-only"
});
const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
variables: { conversationId: selectedConversation }
});
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markConversationRead] = useMutation(MARK_MESSAGES_AS_READ_BY_CONVERSATION, {
// Subscription for conversation updates
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
skip: socket?.connected,
variables: { conversationId: selectedConversation },
refetchQueries: ["UNREAD_CONVERSATION_COUNT"],
update(cache) {
cache.modify({
id: cache.identify({
__typename: "conversations",
id: selectedConversation
}),
fields: {
messages_aggregate(cached) {
return { aggregate: { count: 0 } };
onData: ({ data: subscriptionResult, client }) => {
// Extract the messages array from the result
const messages = subscriptionResult?.data?.messages;
if (!messages || messages.length === 0) {
console.warn("No messages found in subscription result.");
return;
}
messages.forEach((message) => {
const messageRef = client.cache.identify(message);
// Write the new message to the cache
client.cache.writeFragment({
id: messageRef,
fragment: gql`
fragment NewMessage on messages {
id
status
text
isoutbound
image
image_path
userid
created_at
read
}
`,
data: message
});
// Update the conversation cache to include the new message
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
fields: {
messages(existingMessages = []) {
const alreadyExists = existingMessages.some((msg) => msg.__ref === messageRef);
if (alreadyExists) return existingMessages;
return [...existingMessages, { __ref: messageRef }];
},
updated_at() {
return message.created_at;
}
}
}
});
});
}
});
const unreadCount =
data &&
data.messages &&
data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0);
const updateCacheWithReadMessages = useCallback(
(conversationId, messageIds) => {
if (!conversationId || !messageIds?.length) return;
const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true);
await markConversationRead({});
await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid
messageIds.forEach((messageId) => {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: messageId }),
fields: {
read: () => true
}
});
});
setMarkingAsReadInProgress(false);
},
[client.cache]
);
// WebSocket event handlers
useEffect(() => {
if (!socket?.connected) return;
const handleConversationChange = (data) => {
if (data.type === "conversation-marked-read") {
const { conversationId, messageIds } = data;
updateCacheWithReadMessages(conversationId, messageIds);
}
};
socket.on("conversation-changed", handleConversationChange);
return () => {
socket.off("conversation-changed", handleConversationChange);
};
}, [socket, updateCacheWithReadMessages]);
// Join and leave conversation via WebSocket
useEffect(() => {
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
socket.emit("join-bodyshop-conversation", {
bodyshopId: bodyshop.id,
conversationId: selectedConversation
});
return () => {
socket.emit("leave-bodyshop-conversation", {
bodyshopId: bodyshop.id,
conversationId: selectedConversation
});
};
}, [socket, bodyshop, selectedConversation]);
// Mark conversation as read
const handleMarkConversationAsRead = async () => {
if (!convoData || markingAsReadInProgress) return;
const conversation = convoData.conversations_by_pk;
if (!conversation) return;
const unreadMessageIds = conversation.messages
?.filter((message) => !message.read && !message.isoutbound)
.map((message) => message.id);
if (unreadMessageIds?.length > 0) {
setMarkingAsReadInProgress(true);
try {
await axios.post("/sms/markConversationRead", {
conversation,
imexshopid: bodyshop?.imexshopid,
bodyshopid: bodyshop?.id
});
updateCacheWithReadMessages(selectedConversation, unreadMessageIds);
} catch (error) {
console.error("Error marking conversation as read:", error.message);
} finally {
setMarkingAsReadInProgress(false);
}
}
};
return (
<ChatConversationComponent
subState={[loading || convoLoading, error || convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []}
subState={[convoLoading, convoError]}
conversation={convoData?.conversations_by_pk || {}}
messages={convoData?.conversations_by_pk?.messages || []}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
);
}
export default connect(mapStateToProps)(ChatConversationContainer);

View File

@@ -1,14 +1,27 @@
import { PlusOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Input, notification, Spin, Tag, Tooltip } from "antd";
import React, { useState } from "react";
import { Input, Spin, Tag, Tooltip } from "antd";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function ChatLabel({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export function ChatLabel({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const { socket } = useContext(SocketContext);
const notification = useNotification();
const { t } = useTranslation();
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);
@@ -26,6 +39,14 @@ export default function ChatLabel({ conversation }) {
})
});
} else {
if (socket) {
socket.emit("conversation-modified", {
type: "label-updated",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
label: value
});
}
setEditing(false);
}
} catch (error) {
@@ -57,3 +78,5 @@ export default function ChatLabel({ conversation }) {
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatLabel);

View File

@@ -1,106 +1,87 @@
import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import React, { useEffect, useRef } from "react";
import { MdDone, MdDoneAll } from "react-icons/md";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Virtuoso } from "react-virtuoso";
import { renderMessage } from "./renderMessage";
import "./chat-message-list.styles.scss";
export default function ChatMessageListComponent({ messages }) {
const virtualizedListRef = useRef(null);
const virtuosoRef = useRef(null);
const [atBottom, setAtBottom] = useState(true);
const loadedImagesRef = useRef(0);
const _cache = new CellMeasurerCache({
fixedWidth: true,
// minHeight: 50,
defaultHeight: 100
});
const scrollToBottom = (renderedrows) => {
//console.log("Scrolling to", messages.length);
// !!virtualizedListRef.current &&
// virtualizedListRef.current.scrollToRow(messages.length);
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React.
const handleScrollStateChange = (isAtBottom) => {
setAtBottom(isAtBottom);
};
useEffect(scrollToBottom, [messages]);
const _rowRenderer = ({ index, key, parent, style }) => {
return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div
ref={registerChild}
onLoad={measure}
style={style}
className={`${messages[index].isoutbound ? "mine messages" : "yours messages"}`}
>
<div className="message msgmargin">
{MessageRender(messages[index])}
{StatusRender(messages[index].status)}
</div>
{messages[index].isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: messages[index].userid,
time: dayjs(messages[index].created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</div>
)}
</CellMeasurer>
);
const resetImageLoadState = () => {
loadedImagesRef.current = 0;
};
const preloadImages = useCallback((imagePaths, onComplete) => {
resetImageLoadState();
if (imagePaths.length === 0) {
onComplete();
return;
}
imagePaths.forEach((url) => {
const img = new Image();
img.src = url;
img.onload = img.onerror = () => {
loadedImagesRef.current += 1;
if (loadedImagesRef.current === imagePaths.length) {
onComplete();
}
};
});
}, []);
// Ensure all images are loaded on initial render
useEffect(() => {
const imagePaths = messages
.filter((message) => message.image && message.image_path?.length > 0)
.flatMap((message) => message.image_path);
preloadImages(imagePaths, () => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
align: "end",
behavior: "auto"
});
}
});
}, [messages, preloadImages]);
// Handle scrolling when new messages are added
useEffect(() => {
if (!atBottom) return;
const latestMessage = messages[messages.length - 1];
const imagePaths = latestMessage?.image_path || [];
preloadImages(imagePaths, () => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
align: "end",
behavior: "smooth"
});
}
});
}, [messages, atBottom, preloadImages]);
return (
<div className="chat">
<AutoSizer>
{({ height, width }) => (
<List
ref={virtualizedListRef}
width={width}
height={height}
rowHeight={_cache.rowHeight}
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
<Virtuoso
ref={virtuosoRef}
data={messages}
overscan={!!messages.reduce((acc, message) => acc + (message.image_path?.length || 0), 0) ? messages.length : 0}
itemContent={(index) => renderMessage(messages, index)}
followOutput={(isAtBottom) => handleScrollStateChange(isAtBottom)}
initialTopMostItemIndex={messages.length - 1}
style={{ height: "100%", width: "100%" }}
/>
</div>
);
}
const MessageRender = (message) => {
return (
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
</Tooltip>
);
};
const StatusRender = (status) => {
switch (status) {
case "sent":
return <Icon component={MdDone} className="message-icon" />;
case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />;
default:
return null;
}
};

View File

@@ -1,119 +1,131 @@
.message-icon {
//position: absolute;
// bottom: 0rem;
color: whitesmoke;
border: #000000;
position: absolute;
margin: 0 0.1rem;
bottom: 0.1rem;
right: 0.3rem;
z-index: 5;
}
.chat {
flex: 1;
//width: 300px;
//border: solid 1px #eee;
display: flex;
flex-direction: column;
margin: 0.8rem 0rem;
height: 100%;
width: 100%;
}
.archive-button {
height: 20px;
border-radius: 4px;
}
.chat-title {
margin-bottom: 5px;
}
.messages {
//margin-top: 30px;
display: flex;
flex-direction: column;
padding: 0.5rem; // Prevent edge clipping
}
.message {
position: relative;
border-radius: 20px;
padding: 0.25rem 0.8rem;
//margin-top: 5px;
// margin-bottom: 5px;
//display: inline-block;
word-wrap: break-word;
.message-img {
&-img {
max-width: 10rem;
max-height: 10rem;
object-fit: contain;
margin: 0.2rem;
border-radius: 4px;
}
&-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.yours {
align-items: flex-start;
.chat-send-message-button{
margin: 0.3rem;
padding-left: 0.5rem;
}
.message-icon {
position: absolute;
bottom: 0.1rem;
right: 0.3rem;
margin: 0 0.1rem;
color: whitesmoke;
z-index: 5;
}
.msgmargin {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
margin: 0.1rem 0;
}
.yours .message {
margin-right: 20%;
background-color: #eee;
position: relative;
.yours,
.mine {
display: flex;
flex-direction: column;
.message {
position: relative;
&:last-child:before,
&:last-child:after {
content: "";
position: absolute;
bottom: 0;
height: 20px;
width: 20px;
z-index: 0;
}
&:last-child:after {
width: 10px;
background: white;
z-index: 1;
}
}
}
.yours .message.last:before {
content: "";
position: absolute;
z-index: 0;
bottom: 0;
left: -7px;
height: 20px;
width: 20px;
background: #eee;
border-bottom-right-radius: 15px;
}
.yours .message.last:after {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
left: -10px;
width: 10px;
height: 20px;
background: white;
border-bottom-right-radius: 10px;
/* "Yours" (incoming) message styles */
.yours {
align-items: flex-start;
.message {
margin-right: 20%;
background-color: #eee;
&:last-child:before {
left: -7px;
background: #eee;
border-bottom-right-radius: 15px;
}
&:last-child:after {
left: -10px;
border-bottom-right-radius: 10px;
}
}
}
/* "Mine" (outgoing) message styles */
.mine {
align-items: flex-end;
.message {
color: white;
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
padding-bottom: 0.6rem;
&:last-child:before {
right: -8px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
border-bottom-left-radius: 15px;
}
&:last-child:after {
right: -10px;
border-bottom-left-radius: 10px;
}
}
}
.mine .message {
color: white;
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
position: relative;
padding-bottom: 0.6rem;
}
.mine .message.last:before {
content: "";
position: absolute;
z-index: 0;
bottom: 0;
right: -8px;
height: 20px;
width: 20px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
border-bottom-left-radius: 15px;
}
.mine .message.last:after {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
right: -10px;
width: 10px;
height: 20px;
background: white;
border-bottom-left-radius: 10px;
.virtuoso-container {
flex: 1;
overflow: auto;
}

View File

@@ -0,0 +1,52 @@
import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => {
const message = messages[index];
return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{/* Render images if available */}
{message.image && message.image_path?.length > 0 && (
<div className="message-images">
{message.image_path.map((url, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={url} target="_blank" rel="noopener noreferrer">
<img alt="Received" className="message-img" src={url} />
</a>
</div>
))}
</div>
)}
{/* Render text if available */}
{message.text && <div>{message.text}</div>}
</div>
</Tooltip>
{/* Message status icons */}
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
</div>
)}
</div>
{/* Outbound message metadata */}
{message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,
time: dayjs(message.created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</div>
);
};

View File

@@ -1,11 +1,12 @@
import { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd";
import React from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -17,8 +18,10 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const { socket } = useContext(SocketContext);
const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber });
openChatByPhone({ phone_num: values.phoneNumber, socket });
form.resetFields();
};

View File

@@ -1,6 +1,5 @@
import { notification } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import React from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
@@ -9,6 +8,8 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -21,6 +22,9 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
const { t } = useTranslation();
const { socket } = useContext(SocketContext);
const notification = useNotification();
if (!phone) return <></>;
if (!bodyshop.messagingservicesid) return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>;
@@ -33,7 +37,7 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobi
const p = parsePhoneNumber(phone, "CA");
if (searchingForConversation) return; //This is to prevent finding the same thing twice.
if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid });
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid, socket });
} else {
notification["error"]({ message: t("messaging.error.invalidphone") });
}

View File

@@ -1,7 +1,7 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useLazyQuery, useQuery } from "@apollo/client";
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -13,61 +13,102 @@ import ChatConversationContainer from "../chat-conversation/chat-conversation.co
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation();
const [pollInterval, setpollInterval] = useState(0);
const [pollInterval, setPollInterval] = useState(0);
const { socket } = useContext(SocketContext);
const client = useApolloClient(); // Apollo Client instance for cache operations
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
...(pollInterval > 0 ? { pollInterval } : {})
});
const [getConversations, { loading, data, refetch, fetchMore }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
// Lazy query for conversations
const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !chatVisible,
...(pollInterval > 0 ? { pollInterval } : {})
});
const fcmToken = sessionStorage.getItem("fcmtoken");
// Query for unread count when chat is not visible
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: chatVisible, // Skip when chat is visible
...(pollInterval > 0 ? { pollInterval } : {})
});
// Socket connection status
useEffect(() => {
if (fcmToken) {
setpollInterval(0);
} else {
setpollInterval(60000);
}
}, [fcmToken]);
const handleSocketStatus = () => {
if (socket?.connected) {
setPollInterval(15 * 60 * 1000); // 15 minutes
} else {
setPollInterval(60 * 1000); // 60 seconds
}
};
handleSocketStatus();
if (socket) {
socket.on("connect", handleSocketStatus);
socket.on("disconnect", handleSocketStatus);
}
return () => {
if (socket) {
socket.off("connect", handleSocketStatus);
socket.off("disconnect", handleSocketStatus);
}
};
}, [socket]);
// Fetch conversations when chat becomes visible
useEffect(() => {
if (chatVisible)
getConversations({
variables: {
offset: 0
}
}).catch((err) => {
console.error(`Error fetching conversations: ${(err, err.message || "")}`);
});
}, [chatVisible, getConversations]);
const loadMoreConversations = useCallback(() => {
if (data)
fetchMore({
variables: {
offset: data.conversations.length
}
});
}, [data, fetchMore]);
// Get unread count from the cache
const unreadCount = (() => {
if (chatVisible) {
try {
const cachedData = client.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0;
if (!cachedData?.conversations) return 0;
// Aggregate unread message count
return cachedData.conversations.reduce((total, conversation) => {
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
return total + unread;
}, 0);
} catch (error) {
console.warn("Unread count not found in cache:", error);
return 0; // Fallback if not in cache
}
} else if (unreadData?.messages_aggregate?.aggregate?.count) {
// Use the unread count from the query result
return unreadData.messages_aggregate.aggregate.count;
}
return 0;
})();
return (
<Badge count={unreadCount}>
@@ -81,7 +122,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
<InfoCircleOutlined />
</Tooltip>
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
{pollInterval > 0 && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
{!socket?.connected && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
</Space>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
@@ -93,10 +134,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
{loading ? (
<LoadingSpinner />
) : (
<ChatConversationListComponent
conversationList={data ? data.conversations : []}
loadMoreConversations={loadMoreConversations}
/>
<ChatConversationListComponent conversationList={data ? data.conversations : []} />
)}
</Col>
<Col span={16}>{selectedConversation ? <ChatConversationContainer /> : null}</Col>

View File

@@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect";
import { setEmailOptions } from "../../redux/email/email.actions";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({});
@@ -15,6 +16,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatPrintButton({ conversation }) {
const [loading, setLoading] = useState(false);
const notification = useNotification();
const generateDocument = (type) => {
setLoading(true);
@@ -27,7 +29,8 @@ export function ChatPrintButton({ conversation }) {
subject: TemplateList("messaging").conversation_list.subject
},
type,
conversation.id
conversation.id,
notification
).catch((e) => {
console.warn("Something went wrong generating a document.");
});

Some files were not shown because too many files have changed in this diff Show More