diff --git a/client/src/components/error-boundary/error-boundary.component.jsx b/client/src/components/error-boundary/error-boundary.component.jsx index c0d4b48b1..20962704a 100644 --- a/client/src/components/error-boundary/error-boundary.component.jsx +++ b/client/src/components/error-boundary/error-boundary.component.jsx @@ -38,7 +38,7 @@ class ErrorBoundary extends React.Component { } handleErrorSubmit = () => { - window.$crisp.push([ + window.$crisp?.push([ "do", "message:send", [ @@ -53,7 +53,7 @@ class ErrorBoundary extends React.Component { ] ]); - window.$crisp.push(["do", "chat:open"]); + window.$crisp?.push(["do", "chat:open"]); // const errorDescription = `**Please add relevant details about what you were doing before you encountered this issue** @@ -78,7 +78,7 @@ class ErrorBoundary extends React.Component { if (this.state.hasErrored === true) { logImEXEvent("error_boundary_rendered", { error, info }); - window.$crisp.push([ + window.$crisp?.push([ "set", "session:event", [ diff --git a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx index ab72147b4..a6cd07770 100644 --- a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx @@ -1,7 +1,7 @@ import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; import { Button, Card, Col, Row, Space } from "antd"; import { useEffect, useState } from "react"; -import { Gallery } from "react-grid-gallery"; +import { Gallery } from "../../vendor/react-grid-gallery"; import { useTranslation } from "react-i18next"; import Lightbox from "react-image-lightbox"; import "react-image-lightbox/style.css"; diff --git a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.external.component.jsx b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.external.component.jsx index 34979d8ca..1d78349c8 100644 --- a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.external.component.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.external.component.jsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { Gallery } from "react-grid-gallery"; +import { Gallery } from "../../vendor/react-grid-gallery"; import { useTranslation } from "react-i18next"; import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents.utility"; diff --git a/client/src/pages/csi/csi.container.page.jsx b/client/src/pages/csi/csi.container.page.jsx index 975b7c53d..24eee7da3 100644 --- a/client/src/pages/csi/csi.container.page.jsx +++ b/client/src/pages/csi/csi.container.page.jsx @@ -34,7 +34,7 @@ export function CsiContainerPage({ currentUser }) { const getAxiosData = useCallback(async () => { try { try { - window.$crisp.push(["do", "chat:hide"]); + window.$crisp?.push(["do", "chat:hide"]); } catch { console.log("Unable to attach to crisp instance. "); } diff --git a/client/src/redux/user/user.sagas.js b/client/src/redux/user/user.sagas.js index ce727c01e..0501e9918 100644 --- a/client/src/redux/user/user.sagas.js +++ b/client/src/redux/user/user.sagas.js @@ -238,7 +238,7 @@ export function* signInSuccessSaga({ payload }) { LogRocket.identify(payload.email); try { - window.$crisp.push(["set", "user:nickname", [payload.displayName || payload.email]]); + window.$crisp?.push(["set", "user:nickname", [payload.displayName || payload.email]]); InstanceRenderManager({ executeFunction: true, @@ -269,9 +269,9 @@ export function* signInSuccessSaga({ payload }) { ] : []) ]; - window.$crisp.push(["set", "session:segments", [segs]]); + window.$crisp?.push(["set", "session:segments", [segs]]); if (isParts) { - window.$crisp.push(["do", "chat:hide"]); + window.$crisp?.push(["do", "chat:hide"]); } } catch { // no-op @@ -359,9 +359,9 @@ export function* SetAuthLevelFromShopDetails({ payload }) { try { //amplitude.setGroup('Shop', payload.shopname); - window.$crisp.push(["set", "user:company", [payload.shopname]]); + window.$crisp?.push(["set", "user:company", [payload.shopname]]); if (authRecord[0] && authRecord[0].user.validemail) { - window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]); + window.$crisp?.push(["set", "user:email", [authRecord[0].user.email]]); } // Build consolidated Crisp segments including instance, region, features, and parts mode @@ -402,10 +402,10 @@ export function* SetAuthLevelFromShopDetails({ payload }) { segments.push(InstanceRenderManager({ imex: "ImexPartsManagement", rome: "RomePartsManagement" })); } - window.$crisp.push(["set", "session:segments", [segments]]); + window.$crisp?.push(["set", "session:segments", [segments]]); // Hide/show Crisp chat based on parts mode or features - window.$crisp.push(["do", isParts ? "chat:hide" : "chat:show"]); + window.$crisp?.push(["do", isParts ? "chat:hide" : "chat:show"]); InstanceRenderManager({ executeFunction: true, diff --git a/client/src/vendor/react-grid-gallery/CheckButton.jsx b/client/src/vendor/react-grid-gallery/CheckButton.jsx new file mode 100644 index 000000000..4b3e654cd --- /dev/null +++ b/client/src/vendor/react-grid-gallery/CheckButton.jsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import * as styles from "./styles"; + +export const CheckButton = ({ + isSelected = false, + isVisible = true, + onClick, + color = "#FFFFFFB2", + selectedColor = "#4285F4FF", + hoverColor = "#FFFFFFFF", +}) => { + const [hover, setHover] = useState(false); + + const circleStyle = { display: isSelected ? "block" : "none" }; + const fillColor = isSelected ? selectedColor : hover ? hoverColor : color; + + const handleMouseOver = () => setHover(true); + const handleMouseOut = () => setHover(false); + + return ( +
+ + + + + + + + + + + +
+ ); +}; diff --git a/client/src/vendor/react-grid-gallery/Gallery.jsx b/client/src/vendor/react-grid-gallery/Gallery.jsx new file mode 100644 index 000000000..7992f8efa --- /dev/null +++ b/client/src/vendor/react-grid-gallery/Gallery.jsx @@ -0,0 +1,63 @@ +import { Image } from "./Image"; +import { useContainerWidth } from "./useContainerWidth"; +import { buildLayoutFlat } from "./buildLayout"; +import * as styles from "./styles"; + +export const Gallery = ({ + images, + id = "ReactGridGallery", + enableImageSelection = true, + onSelect = () => {}, + rowHeight = 180, + maxRows, + margin = 2, + defaultContainerWidth = 0, + onClick = () => {}, + tileViewportStyle, + thumbnailStyle, + tagStyle, + thumbnailImageComponent +}) => { + const { containerRef, containerWidth } = useContainerWidth(defaultContainerWidth); + + const thumbnails = buildLayoutFlat(images, { + containerWidth, + maxRows, + rowHeight, + margin + }); + + const handleSelect = (index, event) => { + event.preventDefault(); + onSelect(index, images[index], event); + }; + + const handleClick = (index, event) => { + onClick(index, images[index], event); + }; + + return ( +
+
+ {thumbnails.map((item, index) => ( + + ))} +
+
+ ); +}; + +Gallery.displayName = "Gallery"; diff --git a/client/src/vendor/react-grid-gallery/Image.jsx b/client/src/vendor/react-grid-gallery/Image.jsx new file mode 100644 index 000000000..47ad61cb0 --- /dev/null +++ b/client/src/vendor/react-grid-gallery/Image.jsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { CheckButton } from "./CheckButton"; +import * as styles from "./styles"; +import { getStyle } from "./styles"; + +export const Image = ({ + item, + thumbnailImageComponent: ThumbnailImageComponent, + isSelectable = true, + thumbnailStyle, + tagStyle, + tileViewportStyle, + margin, + index, + onSelect, + onClick, +}) => { + const styleContext = { item }; + + const [hover, setHover] = useState(false); + + const thumbnailProps = { + key: index, + "data-testid": "grid-gallery-item_thumbnail", + src: item.src, + alt: item.alt ? item.alt : "", + title: typeof item.caption === "string" ? item.caption : null, + style: getStyle(thumbnailStyle, styles.thumbnail, styleContext), + }; + + const handleCheckButtonClick = (event) => { + if (!isSelectable) { + return; + } + onSelect(index, event); + }; + + const handleViewportClick = (event) => { + onClick(index, event); + }; + + const thumbnailImageProps = { + item, + index, + margin, + onSelect, + onClick, + isSelectable, + tileViewportStyle, + thumbnailStyle, + tagStyle, + }; + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + style={styles.galleryItem({ margin })} + > +
+ +
+ + {!!item.tags && ( +
+ {item.tags.map((tag, index) => ( +
+ + {tag.value} + +
+ ))} +
+ )} + + {!!item.customOverlay && ( +
+ {item.customOverlay} +
+ )} + +
+ +
+ {ThumbnailImageComponent ? ( + + ) : ( + + )} +
+ {item.thumbnailCaption && ( +
+ {item.thumbnailCaption} +
+ )} +
+ ); +}; diff --git a/client/src/vendor/react-grid-gallery/buildLayout.js b/client/src/vendor/react-grid-gallery/buildLayout.js new file mode 100644 index 000000000..cc6f56bb5 --- /dev/null +++ b/client/src/vendor/react-grid-gallery/buildLayout.js @@ -0,0 +1,100 @@ +const calculateCutOff = ( + items, + totalRowWidth, + protrudingWidth +) => { + const cutOff = []; + let cutSum = 0; + for (let i in items) { + const item = items[i]; + const fractionOfWidth = item.scaledWidth / totalRowWidth; + cutOff[i] = Math.floor(fractionOfWidth * protrudingWidth); + cutSum += cutOff[i]; + } + + let stillToCutOff = protrudingWidth - cutSum; + while (stillToCutOff > 0) { + for (let i in cutOff) { + cutOff[i]++; + stillToCutOff--; + if (stillToCutOff < 0) break; + } + } + return cutOff; +}; + +const getRow = ( + images, + { containerWidth, rowHeight, margin } +) => { + const row = []; + const imgMargin = 2 * margin; + const items = [...images]; + + let totalRowWidth = 0; + while (items.length > 0 && totalRowWidth < containerWidth) { + const item = items.shift(); + const scaledWidth = Math.floor(rowHeight * (item.width / item.height)); + const extendedItem = { + ...item, + scaledHeight: rowHeight, + scaledWidth, + viewportWidth: scaledWidth, + marginLeft: 0, + }; + row.push(extendedItem); + totalRowWidth += extendedItem.scaledWidth + imgMargin; + } + + const protrudingWidth = totalRowWidth - containerWidth; + if (row.length > 0 && protrudingWidth > 0) { + const cutoff = calculateCutOff(row, totalRowWidth, protrudingWidth); + for (const i in row) { + const pixelsToRemove = cutoff[i]; + const item = row[i]; + item.marginLeft = -Math.abs(Math.floor(pixelsToRemove / 2)); + item.viewportWidth = item.scaledWidth - pixelsToRemove; + } + } + + return [row, items]; +}; + +const getRows = ( + images, + options, + rows = [] +) => { + const [row, imagesLeft] = getRow(images, options); + const nextRows = [...rows, row]; + + if (options.maxRows && nextRows.length >= options.maxRows) { + return nextRows; + } + if (imagesLeft.length) { + return getRows(imagesLeft, options, nextRows); + } + return nextRows; +}; + +export const buildLayout = ( + images, + { containerWidth, maxRows, rowHeight, margin } +) => { + rowHeight = typeof rowHeight === "undefined" ? 180 : rowHeight; + margin = typeof margin === "undefined" ? 2 : margin; + + if (!images) return []; + if (!containerWidth) return []; + + const options = { containerWidth, maxRows, rowHeight, margin }; + return getRows(images, options); +}; + +export const buildLayoutFlat = ( + images, + options +) => { + const rows = buildLayout(images, options); + return [].concat.apply([], rows); +}; diff --git a/client/src/vendor/react-grid-gallery/index.js b/client/src/vendor/react-grid-gallery/index.js new file mode 100644 index 000000000..196293106 --- /dev/null +++ b/client/src/vendor/react-grid-gallery/index.js @@ -0,0 +1,3 @@ +export { Gallery } from "./Gallery"; +export { CheckButton } from "./CheckButton"; +export { buildLayout, buildLayoutFlat } from "./buildLayout"; diff --git a/client/src/vendor/react-grid-gallery/styles.js b/client/src/vendor/react-grid-gallery/styles.js new file mode 100644 index 000000000..62f93b703 --- /dev/null +++ b/client/src/vendor/react-grid-gallery/styles.js @@ -0,0 +1,185 @@ +export const getStyle = ( + styleProp, + fallback, + context +) => { + if (typeof styleProp === "function") { + return styleProp(context); + } + if (typeof styleProp === "object") { + return styleProp; + } + return fallback(context); +}; + +const rotationTransformMap = { + 3: "rotate(180deg)", + 2: "rotateY(180deg)", + 4: "rotate(180deg) rotateY(180deg)", + 5: "rotate(270deg) rotateY(180deg)", + 6: "rotate(90deg)", + 7: "rotate(90deg) rotateY(180deg)", + 8: "rotate(270deg)", +}; + +const SELECTION_MARGIN = 16; + +export const gallery = { + display: "flex", + flexWrap: "wrap", +}; + +export const thumbnail = ({ item }) => { + const rotationTransformValue = rotationTransformMap[item.orientation]; + + const style = { + cursor: "pointer", + maxWidth: "none", + width: item.scaledWidth, + height: item.scaledHeight, + marginLeft: item.marginLeft, + marginTop: 0, + transform: rotationTransformValue, + }; + + if (item.isSelected) { + const ratio = item.scaledWidth / item.scaledHeight; + const viewportHeight = item.scaledHeight - SELECTION_MARGIN * 2; + const viewportWidth = item.viewportWidth - SELECTION_MARGIN * 2; + + let height, width; + if (item.scaledWidth > item.scaledHeight) { + width = item.scaledWidth - SELECTION_MARGIN * 2; + height = Math.floor(width / ratio); + } else { + height = item.scaledHeight - SELECTION_MARGIN * 2; + width = Math.floor(height * ratio); + } + + const marginTop = Math.abs(Math.floor((viewportHeight - height) / 2)); + const marginLeft = Math.abs(Math.floor((viewportWidth - width) / 2)); + + style.width = width; + style.height = height; + style.marginLeft = marginLeft === 0 ? 0 : -marginLeft; + style.marginTop = marginTop === 0 ? 0 : -marginTop; + } + + return style; +}; + +export const tileViewport = ({ + item, +}) => { + const styles = { + width: item.viewportWidth, + height: item.scaledHeight, + overflow: "hidden", + }; + if (item.nano) { + styles.background = `url(${item.nano})`; + styles.backgroundSize = "cover"; + styles.backgroundPosition = "center center"; + } + if (item.isSelected) { + styles.width = item.viewportWidth - SELECTION_MARGIN * 2; + styles.height = item.scaledHeight - SELECTION_MARGIN * 2; + styles.margin = SELECTION_MARGIN; + } + return styles; +}; + +export const customOverlay = ({ + hover, +}) => ({ + pointerEvents: "none", + opacity: hover ? 1 : 0, + position: "absolute", + height: "100%", + width: "100%", +}); + +export const galleryItem = ({ margin }) => ({ + margin, + WebkitUserSelect: "none", + position: "relative", + background: "#eee", + padding: "0px", +}); + +export const tileOverlay = ({ + showOverlay, +}) => ({ + pointerEvents: "none", + opacity: 1, + position: "absolute", + height: "100%", + width: "100%", + background: showOverlay + ? "linear-gradient(to bottom,rgba(0,0,0,0.26),transparent 56px,transparent)" + : "none", +}); + +export const tileIconBar = { + pointerEvents: "none", + opacity: 1, + position: "absolute", + height: "36px", + width: "100%", +}; + +export const tileDescription = { + background: "white", + width: "100%", + margin: 0, + userSelect: "text", + WebkitUserSelect: "text", + MozUserSelect: "text", + overflow: "hidden", +}; + +export const bottomBar = { + padding: "2px", + pointerEvents: "none", + position: "absolute", + minHeight: "0px", + maxHeight: "160px", + width: "100%", + bottom: "0px", + overflow: "hidden", +}; + +export const tagItemBlock = { + display: "inline-block", + cursor: "pointer", + pointerEvents: "visible", + margin: "2px", +}; + +export const tagItem = () => ({ + display: "inline", + padding: ".2em .6em .3em", + fontSize: "75%", + fontWeight: "600", + lineHeight: "1", + color: "yellow", + background: "rgba(0,0,0,0.65)", + textAlign: "center", + whiteSpace: "nowrap", + verticalAlign: "baseline", + borderRadius: ".25em", +}); + +export const checkButton = ({ + isVisible, +}) => ({ + visibility: isVisible ? "visible" : "hidden", + background: "none", + float: "left", + width: 36, + height: 36, + border: "none", + padding: 6, + cursor: "pointer", + pointerEvents: "visible", +}); diff --git a/client/src/vendor/react-grid-gallery/useContainerWidth.js b/client/src/vendor/react-grid-gallery/useContainerWidth.js new file mode 100644 index 000000000..5464d51f4 --- /dev/null +++ b/client/src/vendor/react-grid-gallery/useContainerWidth.js @@ -0,0 +1,37 @@ +import { useCallback, useRef, useState } from "react"; + +export function useContainerWidth(defaultContainerWidth) { + const ref = useRef(null); + const observerRef = useRef(); + + const [containerWidth, setContainerWidth] = useState(defaultContainerWidth); + + const containerRef = useCallback((node) => { + observerRef.current?.disconnect(); + observerRef.current = undefined; + + ref.current = node; + + const updateWidth = () => { + if (!ref.current) { + return; + } + let width = ref.current.clientWidth; + try { + width = ref.current.getBoundingClientRect().width; + } catch { + // + } + setContainerWidth(Math.floor(width)); + }; + + updateWidth(); + + if (node && typeof ResizeObserver !== "undefined") { + observerRef.current = new ResizeObserver(updateWidth); + observerRef.current.observe(node); + } + }, []); + + return { containerRef, containerWidth }; +} diff --git a/docker-compose.yml b/docker-compose.yml index 261076522..0662dd9bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,10 +21,11 @@ services: - redis-node-1-data:/data - redis-lock:/redis-lock healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: [ "CMD", "sh", "-c", "redis-cli ping && redis-cli cluster info | grep cluster_state:ok" ] interval: 10s timeout: 5s retries: 10 + start_period: 15s # Redis Node 2 redis-node-2: @@ -39,10 +40,11 @@ services: - redis-node-2-data:/data - redis-lock:/redis-lock healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: [ "CMD", "sh", "-c", "redis-cli ping && redis-cli cluster info | grep cluster_state:ok" ] interval: 10s timeout: 5s retries: 10 + start_period: 15s # Redis Node 3 redis-node-3: @@ -57,10 +59,11 @@ services: - redis-node-3-data:/data - redis-lock:/redis-lock healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: [ "CMD", "sh", "-c", "redis-cli ping && redis-cli cluster info | grep cluster_state:ok" ] interval: 10s timeout: 5s retries: 10 + start_period: 15s # LocalStack: Used to emulate AWS services locally, currently setup for SES # Notes: Set the ENV Debug to 1 for additional logging diff --git a/redis/entrypoint.sh b/redis/entrypoint.sh index a5a527e30..caf1e8f8d 100644 --- a/redis/entrypoint.sh +++ b/redis/entrypoint.sh @@ -28,6 +28,10 @@ if [ ! -f "$LOCKFILE" ]; then --cluster-replicas 0 echo "Redis Cluster initialized." + + # Wait for cluster to be fully ready + echo "Waiting for cluster to be fully operational..." + sleep 3 else echo "Cluster already initialized, skipping initialization." fi diff --git a/server.js b/server.js index 25402623e..099ae3562 100644 --- a/server.js +++ b/server.js @@ -213,7 +213,15 @@ const connectToRedisCluster = async () => { const clusterRetryStrategy = (times) => { const delay = Math.min(CLUSTER_RETRY_BASE_DELAY + times * 50, CLUSTER_RETRY_MAX_DELAY) + Math.random() * CLUSTER_RETRY_JITTER; - logger.log(`Redis cluster not yet ready. Retrying in ${delay.toFixed(2)}ms`, "WARN", "redis", "api"); + // Only log every 5th retry or after 10 attempts to reduce noise during startup + if (times % 5 === 0 || times > 10) { + logger.log( + `Redis cluster not yet ready. Retry attempt ${times}, waiting ${delay.toFixed(2)}ms`, + "WARN", + "redis", + "api" + ); + } return delay; };