Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4fb7b4d8b | |||
| 0420194541 | |||
| 318e0c3eb4 | |||
| 08fe762426 | |||
| d0f5d4c979 | |||
| 8fd3dbe393 | |||
| 621cf212f5 | |||
| ea49e140e9 | |||
| c55736ee86 | |||
| cdb1eb0902 | |||
| e367cc1a16 | |||
| 319057990a | |||
| 85b31e9528 |
@@ -0,0 +1,112 @@
|
|||||||
|
name: Deploy Backend to Production
|
||||||
|
|
||||||
|
# Manual production deploy — pulls pre-built backend image from the registry
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Docker image tag to deploy (commit SHA or version tag, e.g. v1.0.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
BACKEND_IMAGE: git.koppkb.com/kopkb/qms-be
|
||||||
|
DEPLOY_DIR: /home/arrahn/Project/qms
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: host
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.image_tag }}
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login git.koppkb.com \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Verify backend image exists in registry
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
IMAGE_TAG="${{ inputs.image_tag }}"
|
||||||
|
IMAGE="${BACKEND_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
echo "Checking if image exists: ${IMAGE}"
|
||||||
|
if ! docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then
|
||||||
|
echo "ERROR: Image ${IMAGE} does not exist in registry!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ ${IMAGE} found"
|
||||||
|
|
||||||
|
- name: Pull backend image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
IMAGE_TAG="${{ inputs.image_tag }}"
|
||||||
|
docker pull "${BACKEND_IMAGE}:${IMAGE_TAG}"
|
||||||
|
echo "✓ Image pulled successfully"
|
||||||
|
|
||||||
|
- name: Sync compose file and deploy
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
IMAGE_TAG="${{ inputs.image_tag }}"
|
||||||
|
|
||||||
|
mkdir -p "${DEPLOY_DIR}"
|
||||||
|
cp be/prod-compose.yml "${DEPLOY_DIR}/docker-compose.yml"
|
||||||
|
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "ERROR: ${DEPLOY_DIR}/.env is missing on the server"
|
||||||
|
echo "Create it from be/.env.example (or be/.env.production) before deploying."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export IMAGE_TAG
|
||||||
|
export BACKEND_IMAGE
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml pull backend
|
||||||
|
docker compose -f docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
echo "✓ Deployed IMAGE_TAG=${IMAGE_TAG}"
|
||||||
|
|
||||||
|
- name: Verify deployment
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
echo "Waiting for services to start..."
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
for SERVICE in mysql backend; do
|
||||||
|
STATUS=$(docker compose -f docker-compose.yml ps --status running --format '{{.Name}}' "$SERVICE" 2>/dev/null || true)
|
||||||
|
if [ -z "$STATUS" ]; then
|
||||||
|
echo "ERROR: ${SERVICE} is not running"
|
||||||
|
docker compose -f docker-compose.yml ps
|
||||||
|
docker compose -f docker-compose.yml logs --tail=50 "$SERVICE" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ ${SERVICE} is running (${STATUS})"
|
||||||
|
done
|
||||||
|
|
||||||
|
BACKEND_HOST_PORT=$(grep -E '^BACKEND_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
|
||||||
|
BACKEND_HOST_PORT=${BACKEND_HOST_PORT:-8080}
|
||||||
|
|
||||||
|
# No actuator yet — treat any HTTP response as "server is up"
|
||||||
|
for i in 1 2 3 4 5 6 7 8; do
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${BACKEND_HOST_PORT}/" || echo "000")
|
||||||
|
if [ "${CODE}" != "000" ]; then
|
||||||
|
echo "✓ Backend is responding on port ${BACKEND_HOST_PORT} (HTTP ${CODE})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for backend... attempt ${i}/8"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "ERROR: Backend is not responding on port ${BACKEND_HOST_PORT}"
|
||||||
|
docker compose -f docker-compose.yml logs --tail=50 backend || true
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
name: Build Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# branches:
|
||||||
|
# - main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-backend:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login git.koppkb.com \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build & push backend image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE="git.koppkb.com/kopkb/qms-be"
|
||||||
|
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
|
||||||
|
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
|
||||||
|
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building and pushing backend: ${TAGS}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
|
||||||
|
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
||||||
|
-f be/Dockerfile \
|
||||||
|
${TAGS} \
|
||||||
|
--push \
|
||||||
|
be
|
||||||
|
|
||||||
|
build-admin:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login git.koppkb.com \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build & push admin frontend image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE="git.koppkb.com/kopkb/qms-fe-admin"
|
||||||
|
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
|
||||||
|
API_URL="${{ secrets.VITE_API_URL }}"
|
||||||
|
# Branch QR display URL only (public domain via nginx)
|
||||||
|
BRANCH_QR_BASE_URL="${{ secrets.VITE_BRANCH_QR_BASE_URL }}"
|
||||||
|
if [ -z "${BRANCH_QR_BASE_URL}" ]; then
|
||||||
|
BRANCH_QR_BASE_URL="https://qms-customer.erahn.com.my"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${API_URL}" ]; then
|
||||||
|
echo "ERROR: Set Gitea secret VITE_API_URL (e.g. http://172.16.6.200:8080)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
|
||||||
|
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building and pushing admin: ${TAGS}"
|
||||||
|
echo "VITE_API_URL=${API_URL}"
|
||||||
|
echo "VITE_BRANCH_QR_BASE_URL=${BRANCH_QR_BASE_URL}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--build-arg VITE_API_URL="${API_URL}" \
|
||||||
|
--build-arg VITE_BRANCH_QR_BASE_URL="${BRANCH_QR_BASE_URL}" \
|
||||||
|
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
|
||||||
|
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
||||||
|
-f fe/web/admin-app/Dockerfile \
|
||||||
|
${TAGS} \
|
||||||
|
--push \
|
||||||
|
fe/web/admin-app
|
||||||
|
|
||||||
|
build-teller:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login git.koppkb.com \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build & push teller frontend image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE="git.koppkb.com/kopkb/qms-fe-teller"
|
||||||
|
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
|
||||||
|
API_URL="${{ secrets.VITE_API_URL }}"
|
||||||
|
|
||||||
|
if [ -z "${API_URL}" ]; then
|
||||||
|
echo "ERROR: Set Gitea secret VITE_API_URL (e.g. http://172.16.6.200:8080)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
|
||||||
|
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GOLD_PRICE_URL="${{ secrets.VITE_GOLD_PRICE_URL }}"
|
||||||
|
if [ -z "${GOLD_PRICE_URL}" ]; then
|
||||||
|
GOLD_PRICE_URL="https://apiujrah.erahn.com.my/api/harga_emas"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building and pushing teller: ${TAGS}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--build-arg VITE_API_URL="${API_URL}" \
|
||||||
|
--build-arg VITE_GOLD_PRICE_URL="${GOLD_PRICE_URL}" \
|
||||||
|
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
|
||||||
|
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
||||||
|
-f fe/web/teller-app/Dockerfile \
|
||||||
|
${TAGS} \
|
||||||
|
--push \
|
||||||
|
fe/web/teller-app
|
||||||
|
|
||||||
|
build-customer:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login git.koppkb.com \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Build & push customer frontend image
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE="git.koppkb.com/kopkb/qms-fe-customer"
|
||||||
|
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
|
||||||
|
API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}"
|
||||||
|
if [ -z "${API_URL}" ]; then
|
||||||
|
API_URL="${{ secrets.VITE_API_URL }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${API_URL}" ]; then
|
||||||
|
echo "ERROR: Set Gitea secret NEXT_PUBLIC_API_URL or VITE_API_URL (e.g. http://172.16.6.200:8080)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
|
||||||
|
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building and pushing customer: ${TAGS}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL="${API_URL}" \
|
||||||
|
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
|
||||||
|
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
||||||
|
-f fe/web/customer-app/Dockerfile \
|
||||||
|
${TAGS} \
|
||||||
|
--push \
|
||||||
|
fe/web/customer-app
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Deploy Frontend to Production
|
||||||
|
|
||||||
|
# Manual production deploy — pulls pre-built admin + teller + customer images
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Docker image tag to deploy (commit SHA or version tag, e.g. v1.0.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
ADMIN_IMAGE: git.koppkb.com/kopkb/qms-fe-admin
|
||||||
|
TELLER_IMAGE: git.koppkb.com/kopkb/qms-fe-teller
|
||||||
|
CUSTOMER_IMAGE: git.koppkb.com/kopkb/qms-fe-customer
|
||||||
|
DEPLOY_DIR: /home/arrahn/Project/qms-fe
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: host
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.image_tag }}
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login git.koppkb.com \
|
||||||
|
-u "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Verify frontend images exist in registry
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
IMAGE_TAG="${{ inputs.image_tag }}"
|
||||||
|
|
||||||
|
for IMAGE in "${ADMIN_IMAGE}:${IMAGE_TAG}" "${TELLER_IMAGE}:${IMAGE_TAG}" "${CUSTOMER_IMAGE}:${IMAGE_TAG}"; do
|
||||||
|
echo "Checking if image exists: ${IMAGE}"
|
||||||
|
if ! docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then
|
||||||
|
echo "ERROR: Image ${IMAGE} does not exist in registry!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ ${IMAGE} found"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Pull frontend images
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
IMAGE_TAG="${{ inputs.image_tag }}"
|
||||||
|
docker pull "${ADMIN_IMAGE}:${IMAGE_TAG}"
|
||||||
|
docker pull "${TELLER_IMAGE}:${IMAGE_TAG}"
|
||||||
|
docker pull "${CUSTOMER_IMAGE}:${IMAGE_TAG}"
|
||||||
|
echo "✓ Images pulled successfully"
|
||||||
|
|
||||||
|
- name: Sync compose file and deploy
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
IMAGE_TAG="${{ inputs.image_tag }}"
|
||||||
|
|
||||||
|
mkdir -p "${DEPLOY_DIR}"
|
||||||
|
cp fe/web/docker-compose.yml "${DEPLOY_DIR}/docker-compose.yml"
|
||||||
|
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "ERROR: ${DEPLOY_DIR}/.env is missing on the server"
|
||||||
|
echo "Create it from fe/web/.env.example (or fe/web/.env.production) before deploying."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export IMAGE_TAG
|
||||||
|
export ADMIN_IMAGE
|
||||||
|
export TELLER_IMAGE
|
||||||
|
export CUSTOMER_IMAGE
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml pull admin teller customer
|
||||||
|
docker compose -f docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
echo "✓ Deployed IMAGE_TAG=${IMAGE_TAG}"
|
||||||
|
|
||||||
|
- name: Verify deployment
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
echo "Waiting for services to start..."
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
for SERVICE in admin teller customer; do
|
||||||
|
STATUS=$(docker compose -f docker-compose.yml ps --status running --format '{{.Name}}' "$SERVICE" 2>/dev/null || true)
|
||||||
|
if [ -z "$STATUS" ]; then
|
||||||
|
echo "ERROR: ${SERVICE} is not running"
|
||||||
|
docker compose -f docker-compose.yml ps
|
||||||
|
docker compose -f docker-compose.yml logs --tail=50 "$SERVICE" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ ${SERVICE} is running (${STATUS})"
|
||||||
|
done
|
||||||
|
|
||||||
|
ADMIN_HOST_PORT=$(grep -E '^ADMIN_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
|
||||||
|
TELLER_HOST_PORT=$(grep -E '^TELLER_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
|
||||||
|
CUSTOMER_HOST_PORT=$(grep -E '^CUSTOMER_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
|
||||||
|
ADMIN_HOST_PORT=${ADMIN_HOST_PORT:-5000}
|
||||||
|
TELLER_HOST_PORT=${TELLER_HOST_PORT:-3001}
|
||||||
|
CUSTOMER_HOST_PORT=${CUSTOMER_HOST_PORT:-3000}
|
||||||
|
|
||||||
|
check_http() {
|
||||||
|
NAME=$1
|
||||||
|
PORT=$2
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/" || echo "000")
|
||||||
|
if [ "${CODE}" != "000" ]; then
|
||||||
|
echo "✓ ${NAME} is responding on port ${PORT} (HTTP ${CODE})"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for ${NAME}... attempt ${i}/6"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "ERROR: ${NAME} is not responding on port ${PORT}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_http "admin" "${ADMIN_HOST_PORT}"
|
||||||
|
check_http "teller" "${TELLER_HOST_PORT}"
|
||||||
|
check_http "customer" "${CUSTOMER_HOST_PORT}"
|
||||||
@@ -51,3 +51,10 @@ The backend of the queue management system will be developed using Java's Spring
|
|||||||
- [Ahmed Ljubuncic](https://github.com/aljubuncic)
|
- [Ahmed Ljubuncic](https://github.com/aljubuncic)
|
||||||
- [Vedran Mujic](https://github.com/vmujic1)
|
- [Vedran Mujic](https://github.com/vmujic1)
|
||||||
- [Amar Tahirovic](https://github.com/amarderschrecklicher)
|
- [Amar Tahirovic](https://github.com/amarderschrecklicher)
|
||||||
|
|
||||||
|
## API ENV GUIDE
|
||||||
|
VITE_API_URL: API for teller and admin (also customer fallback in CI)
|
||||||
|
NEXT_PUBLIC_API_URL: API for customer
|
||||||
|
VITE_BRANCH_QR_BASE_URL: public customer base URL encoded into admin branch QR
|
||||||
|
|
||||||
|
Match these in Gitea secrets and in `fe/web/.env` on the deploy host (ports/images only matter at deploy; API URLs matter at image build).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[x] create qr and teller account
|
||||||
|
[x] increase size of gold display
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
target/
|
||||||
|
uploads/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Host ports (change these if they collide on your single server)
|
||||||
|
BACKEND_HOST_PORT=8080
|
||||||
|
# Published for DBeaver/tools (container still uses 3306 internally)
|
||||||
|
MYSQL_HOST_PORT=3307
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
# Quote if the password contains special chars like ; # $
|
||||||
|
MYSQL_ROOT_PASSWORD="password"
|
||||||
|
MYSQL_DATABASE=qms
|
||||||
|
|
||||||
|
# App
|
||||||
|
JWT_SECRET_KEY=change-me-to-a-long-random-secret
|
||||||
|
GOOGLE_CLIENT_ID=dummy-google-client-id
|
||||||
|
NOTIFICATIONS_MOCK=true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Host ports (change these if they collide on your single server)
|
||||||
|
BACKEND_HOST_PORT=8080
|
||||||
|
# Published for DBeaver/tools (container still uses 3306 internally)
|
||||||
|
MYSQL_HOST_PORT=3307
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
MYSQL_ROOT_PASSWORD="R8qjjBJDg9NtL2IwcnoFMkn8pktwAZ;wsl"
|
||||||
|
MYSQL_DATABASE=qms
|
||||||
|
|
||||||
|
# App
|
||||||
|
JWT_SECRET_KEY=a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF
|
||||||
|
GOOGLE_CLIENT_ID=dummy-google-client-id
|
||||||
|
NOTIFICATIONS_MOCK=true
|
||||||
@@ -34,3 +34,6 @@ build/
|
|||||||
|
|
||||||
### Uploads ###
|
### Uploads ###
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
|
### Docker / env ###
|
||||||
|
.env
|
||||||
|
|||||||
+27
-3
@@ -1,3 +1,27 @@
|
|||||||
FROM openjdk:17-jdk-alpine
|
# Build stage
|
||||||
COPY target/bbqms-0.0.1-SNAPSHOT.jar ./app.jar
|
# Non-alpine tags are multi-arch (amd64 + arm64). Alpine Maven often has no Mac/ARM image.
|
||||||
ENTRYPOINT ["java", "-jar", "/app.jar"]
|
FROM maven:3.9-eclipse-temurin-17 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Use image mvn (not ./mvnw). Official maven image sets MAVEN_CONFIG=/root/.m2,
|
||||||
|
# which breaks the wrapper with: Unknown lifecycle phase "/root/.m2"
|
||||||
|
RUN mvn -q -DskipTests package
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN groupadd -r qms && useradd -r -g qms qms \
|
||||||
|
&& mkdir -p /app/uploads/ads \
|
||||||
|
&& chown -R qms:qms /app
|
||||||
|
|
||||||
|
COPY --from=build /app/target/bbqms-0.0.1-SNAPSHOT.jar app.jar
|
||||||
|
|
||||||
|
USER qms
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
|||||||
+33
-4
@@ -1,9 +1,38 @@
|
|||||||
## Build instructions
|
## Build instructions (local)
|
||||||
|
|
||||||
- Enter your database details in the **src/main/resources/application.yml** file
|
- Enter your database details in the **src/main/resources/application.yml** file (or use env vars)
|
||||||
- Through IntelliJ simply click on the run button
|
- Through IntelliJ simply click on the run button
|
||||||
- Or through console
|
- Or through console
|
||||||
- ./mvnw dependency:resolve
|
- `./mvnw dependency:resolve`
|
||||||
- ./mvnw spring-boot:run
|
- `./mvnw spring-boot:run`
|
||||||
|
|
||||||
##### **NOTE:** requires Java 17 and MySQL 8
|
##### **NOTE:** requires Java 17 and MySQL 8
|
||||||
|
|
||||||
|
## Docker deployment (single server)
|
||||||
|
|
||||||
|
From the `be/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env if ports 8080 / 3306 are already in use on the server
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Services and default host ports:
|
||||||
|
|
||||||
|
| Service | Container | Host port (configurable) |
|
||||||
|
|----------|-----------|---------------------------|
|
||||||
|
| Backend | qms-backend | `BACKEND_HOST_PORT` → **8080** |
|
||||||
|
| MySQL | qms-mysql | `MYSQL_HOST_PORT` → **3306** |
|
||||||
|
|
||||||
|
API base URL on the server: `http://<server-ip>:8080`
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f backend
|
||||||
|
docker compose down # stop containers (keeps DB volume)
|
||||||
|
docker compose down -v # stop and delete DB/uploads volumes
|
||||||
|
```
|
||||||
|
|||||||
+20
-4
@@ -2,18 +2,34 @@ services:
|
|||||||
mysql:
|
mysql:
|
||||||
image: mysql:8
|
image: mysql:8
|
||||||
container_name: qms-mysql
|
container_name: qms-mysql
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: password
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
|
||||||
MYSQL_DATABASE: qms
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
|
||||||
volumes:
|
volumes:
|
||||||
- qms-mysql-data:/var/lib/mysql
|
- qms-mysql-data:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-ppassword"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"mysqladmin",
|
||||||
|
"ping",
|
||||||
|
"-h",
|
||||||
|
"localhost",
|
||||||
|
"-p${MYSQL_ROOT_PASSWORD:-password}",
|
||||||
|
]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 20
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- qms-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
qms-mysql-data:
|
qms-mysql-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
qms-net:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8
|
||||||
|
container_name: qms-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MYSQL_HOST_PORT:-3307}:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
|
||||||
|
volumes:
|
||||||
|
- qms-mysql-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"mysqladmin",
|
||||||
|
"ping",
|
||||||
|
"-h",
|
||||||
|
"localhost",
|
||||||
|
"-p${MYSQL_ROOT_PASSWORD:-password}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- qms-net
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: ${BACKEND_IMAGE:-qms-backend}:${IMAGE_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: qms-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Host port can be changed via BACKEND_HOST_PORT in .env (default 8080)
|
||||||
|
- "${BACKEND_HOST_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 8080
|
||||||
|
# Use Docker service name "mysql", not localhost
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-qms}?allowPublicKeyRetrieval=true&useSSL=false
|
||||||
|
SPRING_DATASOURCE_USERNAME: root
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF}
|
||||||
|
ADS_UPLOAD_DIR: /app/uploads/ads
|
||||||
|
NOTIFICATIONS_MOCK: ${NOTIFICATIONS_MOCK:-true}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-dummy-google-client-id}
|
||||||
|
volumes:
|
||||||
|
- qms-uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- qms-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
qms-mysql-data:
|
||||||
|
qms-uploads:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
qms-net:
|
||||||
|
driver: bridge
|
||||||
+1
@@ -33,6 +33,7 @@ public class DefaultAdminService implements AdminService {
|
|||||||
private final RoleService roleService;
|
private final RoleService roleService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
|
||||||
public DefaultAdminService(final UserRepository userRepository,
|
public DefaultAdminService(final UserRepository userRepository,
|
||||||
final UserService userService,
|
final UserService userService,
|
||||||
final TwoFactorService twoFactorService,
|
final TwoFactorService twoFactorService,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -30,7 +31,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@PostMapping("/{code}")
|
@PostMapping("/{code}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity getUsers(@RequestBody RoleRequest request, @PathVariable(name = "code") final String tenantCode) {
|
public ResponseEntity<List<UserDto>> getUsers(@RequestBody RoleRequest request,
|
||||||
|
@PathVariable(name = "code") final String tenantCode) {
|
||||||
RoleName roleName;
|
RoleName roleName;
|
||||||
try {
|
try {
|
||||||
roleName = RoleName.valueOf(request.roleName);
|
roleName = RoleName.valueOf(request.roleName);
|
||||||
@@ -52,8 +54,7 @@ public class AdminController {
|
|||||||
return ResponseEntity.ok().body(
|
return ResponseEntity.ok().body(
|
||||||
this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
|
this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
|
||||||
.map(UserDto::fromEntity)
|
.map(UserDto::fromEntity)
|
||||||
.collect(Collectors.toList())
|
.collect(Collectors.toList()));
|
||||||
);
|
|
||||||
} catch (EntityNotFoundException e) {
|
} catch (EntityNotFoundException e) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@PostMapping("/{code}/user")
|
@PostMapping("/{code}/user")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity addUser(@RequestBody final AdminRequest request, @PathVariable(name = "code") final String tenantCode) throws AuthException {
|
public ResponseEntity<UserDto> addUser(@RequestBody final AdminRequest request,
|
||||||
|
@PathVariable(name = "code") final String tenantCode) throws AuthException {
|
||||||
RoleName roleName;
|
RoleName roleName;
|
||||||
try {
|
try {
|
||||||
roleName = RoleName.valueOf(request.roleName);
|
roleName = RoleName.valueOf(request.roleName);
|
||||||
@@ -95,7 +97,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@PutMapping("/{code}/user/{userId}")
|
@PutMapping("/{code}/user/{userId}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity updateAdmin(@RequestBody final UserDto request, @PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
|
public ResponseEntity<SimpleMessageDto> updateAdmin(@RequestBody final UserDto request,
|
||||||
|
@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
|
||||||
if (!this.authService.canChangeTenant(tenantCode)) {
|
if (!this.authService.canChangeTenant(tenantCode)) {
|
||||||
logger.warn("Admin does not belong to the specified tenant");
|
logger.warn("Admin does not belong to the specified tenant");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
@@ -112,7 +115,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@DeleteMapping("/{code}/user/{userId}")
|
@DeleteMapping("/{code}/user/{userId}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity removeAdmin(@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
|
public ResponseEntity<SimpleMessageDto> removeAdmin(@PathVariable(name = "code") final String tenantCode,
|
||||||
|
@PathVariable(name = "userId") final long adminId) {
|
||||||
if (!this.authService.canChangeTenant(tenantCode)) {
|
if (!this.authService.canChangeTenant(tenantCode)) {
|
||||||
logger.warn("Admin does not belong to the specified tenant");
|
logger.warn("Admin does not belong to the specified tenant");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class AdvertisementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/media/{adId}")
|
@GetMapping("/media/{adId}")
|
||||||
public ResponseEntity streamMedia(@PathVariable final long adId) {
|
public ResponseEntity<Resource> streamMedia(@PathVariable final long adId) {
|
||||||
try {
|
try {
|
||||||
final Advertisement advertisement = this.advertisementService.findById(adId);
|
final Advertisement advertisement = this.advertisementService.findById(adId);
|
||||||
final Resource resource = this.advertisementService.loadMedia(adId);
|
final Resource resource = this.advertisementService.loadMedia(adId);
|
||||||
@@ -80,8 +80,7 @@ public class AdvertisementController {
|
|||||||
title,
|
title,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
active
|
active);
|
||||||
);
|
|
||||||
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
|
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
|
||||||
} catch (final Exception exception) {
|
} catch (final Exception exception) {
|
||||||
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
|
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
|
||||||
@@ -121,8 +120,7 @@ public class AdvertisementController {
|
|||||||
request.title(),
|
request.title(),
|
||||||
request.durationSeconds(),
|
request.durationSeconds(),
|
||||||
request.sortOrder(),
|
request.sortOrder(),
|
||||||
request.active()
|
request.active());
|
||||||
);
|
|
||||||
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
|
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
|
||||||
} catch (final Exception exception) {
|
} catch (final Exception exception) {
|
||||||
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
|
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8080}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: qms
|
name: qms
|
||||||
@@ -6,9 +9,9 @@ spring:
|
|||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: none
|
ddl-auto: none
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://localhost:3306/qms
|
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/qms?allowPublicKeyRetrieval=true&useSSL=false}
|
||||||
username: root
|
username: ${SPRING_DATASOURCE_USERNAME:root}
|
||||||
password: password
|
password: ${SPRING_DATASOURCE_PASSWORD:password}
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 100MB
|
max-file-size: 100MB
|
||||||
@@ -18,14 +21,14 @@ spring:
|
|||||||
client:
|
client:
|
||||||
registration:
|
registration:
|
||||||
google:
|
google:
|
||||||
client-id: dummy-google-client-id
|
client-id: ${GOOGLE_CLIENT_ID:dummy-google-client-id}
|
||||||
flyway:
|
flyway:
|
||||||
schemas: qms
|
schemas: qms
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
header-title: Authorization
|
header-title: Authorization
|
||||||
token-prefix: Bearer
|
token-prefix: Bearer
|
||||||
secret-key: a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF
|
secret-key: ${JWT_SECRET_KEY:a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF}
|
||||||
authorities-key: USER_AUTHORITIES
|
authorities-key: USER_AUTHORITIES
|
||||||
token-validity-time: PT30M
|
token-validity-time: PT30M
|
||||||
tfa:
|
tfa:
|
||||||
@@ -35,6 +38,6 @@ tenancy:
|
|||||||
default-code: DFLT
|
default-code: DFLT
|
||||||
notifications:
|
notifications:
|
||||||
expo-url: https://exp.host/--/api/v2/push/send
|
expo-url: https://exp.host/--/api/v2/push/send
|
||||||
mock: true
|
mock: ${NOTIFICATIONS_MOCK:true}
|
||||||
ads:
|
ads:
|
||||||
upload-dir: uploads/ads
|
upload-dir: ${ADS_UPLOAD_DIR:uploads/ads}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Host ports (nginx terminates TLS on 443 and proxies to these)
|
||||||
|
ADMIN_HOST_PORT=5000
|
||||||
|
TELLER_HOST_PORT=3001
|
||||||
|
CUSTOMER_HOST_PORT=3000
|
||||||
|
|
||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
NEXT_PUBLIC_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
|
|
||||||
|
QR_TOKEN_SECRET=qms-branch-qr-v1
|
||||||
|
|
||||||
|
ADMIN_IMAGE=git.koppkb.com/kopkb/qms-fe-admin
|
||||||
|
TELLER_IMAGE=git.koppkb.com/kopkb/qms-fe-teller
|
||||||
|
CUSTOMER_IMAGE=git.koppkb.com/kopkb/qms-fe-customer
|
||||||
|
IMAGE_TAG=local
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
ADMIN_HOST_PORT=5000
|
||||||
|
TELLER_HOST_PORT=3001
|
||||||
|
CUSTOMER_HOST_PORT=3000
|
||||||
|
|
||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
NEXT_PUBLIC_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
|
VITE_GOLD_PRICE_URL=https://apiujrah.erahn.com.my/api/harga_emas
|
||||||
|
|
||||||
|
QR_TOKEN_SECRET=qms-branch-qr-v1
|
||||||
|
|
||||||
|
ADMIN_IMAGE=git.koppkb.com/kopkb/qms-fe-admin
|
||||||
|
TELLER_IMAGE=git.koppkb.com/kopkb/qms-fe-teller
|
||||||
|
CUSTOMER_IMAGE=git.koppkb.com/kopkb/qms-fe-customer
|
||||||
|
IMAGE_TAG=local
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
ADMIN_HOST_PORT=5000
|
||||||
|
TELLER_HOST_PORT=3001
|
||||||
|
CUSTOMER_HOST_PORT=3000
|
||||||
|
|
||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
NEXT_PUBLIC_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
|
VITE_GOLD_PRICE_URL=https://apiujrah.erahn.com.my/api/harga_emas
|
||||||
|
|
||||||
|
QR_TOKEN_SECRET=qms-branch-qr-v1
|
||||||
|
|
||||||
|
ADMIN_IMAGE=git.koppkb.com/kopkb/qms-fe-admin
|
||||||
|
TELLER_IMAGE=git.koppkb.com/kopkb/qms-fe-teller
|
||||||
|
CUSTOMER_IMAGE=git.koppkb.com/kopkb/qms-fe-customer
|
||||||
|
IMAGE_TAG=local
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
!.env.production
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
VITE_BRANCH_QR_BASE_URL=http://localhost:3000
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
FROM node:21-alpine AS build
|
# Build stage
|
||||||
WORKDIR /admin-app
|
FROM node:20-bookworm AS build
|
||||||
COPY package*.json .
|
WORKDIR /app
|
||||||
RUN npm install
|
|
||||||
|
ARG VITE_API_URL=http://localhost:8080
|
||||||
|
ARG VITE_BRANCH_QR_BASE_URL=http://localhost:3000
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_BRANCH_QR_BASE_URL=$VITE_BRANCH_QR_BASE_URL
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 5001
|
# Runtime stage — static SPA
|
||||||
CMD ["npm", "run", "preview"]
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+304
-3
@@ -12,6 +12,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-bootstrap": "^2.10.2",
|
"react-bootstrap": "^2.10.2",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"yup": "^1.4.0"
|
"yup": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -1295,11 +1297,31 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
|
||||||
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA=="
|
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||||
|
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.11",
|
"version": "15.7.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
||||||
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.2.66",
|
"version": "18.2.66",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz",
|
||||||
@@ -1411,7 +1433,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -1695,6 +1716,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001597",
|
"version": "1.0.30001597",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
|
||||||
@@ -1734,6 +1764,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
@@ -1863,6 +1904,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1919,6 +1969,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
@@ -1954,6 +2010,12 @@
|
|||||||
"integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==",
|
"integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz",
|
||||||
@@ -2632,6 +2694,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
|
||||||
@@ -3042,6 +3113,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
|
||||||
@@ -3631,6 +3711,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3647,7 +3736,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3682,6 +3770,15 @@
|
|||||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
@@ -3764,6 +3861,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -3981,6 +4095,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.5",
|
"version": "2.0.0-next.5",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||||
@@ -4195,6 +4324,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -4275,6 +4410,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string.prototype.matchall": {
|
"node_modules/string.prototype.matchall": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz",
|
||||||
@@ -4344,7 +4493,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -4549,6 +4697,13 @@
|
|||||||
"react": ">=15.0.0"
|
"react": ">=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||||
@@ -4734,6 +4889,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
|
||||||
@@ -4753,18 +4914,158 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export const SERVER_URL = 'http://localhost:8080';
|
export const SERVER_URL =
|
||||||
|
import.meta.env.VITE_API_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
export const CUSTOMER_APP_URL =
|
/** Public base URL encoded into branch QR codes (ManageBranchesScreen only). */
|
||||||
import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
|
export const BRANCH_QR_BASE_URL =
|
||||||
|
import.meta.env.VITE_BRANCH_QR_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
|
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN',
|
||||||
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
|
ROLE_BRANCH_ADMIN: 'ROLE_BRANCH_ADMIN',
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { CUSTOMER_APP_URL } from '../constants.js';
|
import { BRANCH_QR_BASE_URL } from '../constants.js';
|
||||||
|
|
||||||
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
|
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export function decodeBranchQrToken(token, secret = getQrSecret()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBranchTicketUrl(tenantCode, branchId) {
|
export function getBranchTicketUrl(tenantCode, branchId) {
|
||||||
const base = CUSTOMER_APP_URL.replace(/\/$/, '');
|
const base = BRANCH_QR_BASE_URL.replace(/\/$/, '');
|
||||||
const token = encodeBranchQrToken(tenantCode, branchId);
|
const token = encodeBranchQrToken(tenantCode, branchId);
|
||||||
return `${base}/q/${token}`;
|
return `${base}/q/${token}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-bookworm AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -22,6 +22,9 @@ type Props = {
|
|||||||
initialBranchId?: number;
|
initialBranchId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Prevents spam of "Dapatkan tiket lain" right after getting a number. */
|
||||||
|
const ANOTHER_TICKET_COOLDOWN_MS = 10_000;
|
||||||
|
|
||||||
function pickLatestTicket(
|
function pickLatestTicket(
|
||||||
tickets: Ticket[],
|
tickets: Ticket[],
|
||||||
branchId?: number
|
branchId?: number
|
||||||
@@ -61,8 +64,27 @@ export default function CustomerTicketFlow({
|
|||||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cooldownLeftMs, setCooldownLeftMs] = useState(0);
|
||||||
const bootstrapped = useRef(false);
|
const bootstrapped = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== "ticket" || !ticket) {
|
||||||
|
setCooldownLeftMs(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = Date.now() - new Date(ticket.createdAt).getTime();
|
||||||
|
setCooldownLeftMs(
|
||||||
|
Math.max(0, ANOTHER_TICKET_COOLDOWN_MS - elapsed)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [step, ticket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bootstrapped.current) return;
|
if (bootstrapped.current) return;
|
||||||
bootstrapped.current = true;
|
bootstrapped.current = true;
|
||||||
@@ -281,6 +303,9 @@ export default function CustomerTicketFlow({
|
|||||||
? "Pilih perkhidmatan untuk mendapatkan nombor."
|
? "Pilih perkhidmatan untuk mendapatkan nombor."
|
||||||
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
|
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
|
||||||
|
|
||||||
|
const canGetAnotherTicket = cooldownLeftMs <= 0;
|
||||||
|
const cooldownSeconds = Math.ceil(cooldownLeftMs / 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
|
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
@@ -423,7 +448,7 @@ export default function CustomerTicketFlow({
|
|||||||
{step === "ticket" && ticket ? (
|
{step === "ticket" && ticket ? (
|
||||||
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
|
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
|
||||||
<p className="text-sm tracking-wide text-zinc-500 uppercase">
|
<p className="text-sm tracking-wide text-zinc-500 uppercase">
|
||||||
Nombor tiket anda
|
Nombor giliran anda
|
||||||
</p>
|
</p>
|
||||||
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
|
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
|
||||||
{ticket.number}
|
{ticket.number}
|
||||||
@@ -432,12 +457,36 @@ export default function CustomerTicketFlow({
|
|||||||
<p>{ticket.service.name}</p>
|
<p>{ticket.service.name}</p>
|
||||||
<p>{ticket.branch.name}</p>
|
<p>{ticket.branch.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-left text-sm text-amber-950"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">Anda sudah mempunyai nombor giliran.</p>
|
||||||
|
<p className="mt-1 text-amber-900">
|
||||||
|
Sila tunggu sehingga nombor anda dipanggil.
|
||||||
|
</p>
|
||||||
|
{!canGetAnotherTicket ? (
|
||||||
|
<p className="mt-2 font-medium tabular-nums text-amber-950">
|
||||||
|
Tiket lain boleh diambil dalam {cooldownSeconds}s
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetFlow}
|
onClick={resetFlow}
|
||||||
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
|
disabled={!canGetAnotherTicket || loading}
|
||||||
|
aria-disabled={!canGetAnotherTicket || loading}
|
||||||
|
className={
|
||||||
|
canGetAnotherTicket
|
||||||
|
? "w-full rounded-md border border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-500 hover:bg-zinc-50 disabled:opacity-60"
|
||||||
|
: "w-full cursor-not-allowed rounded-md bg-zinc-200 px-4 py-3 text-sm font-medium text-zinc-500"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Dapatkan tiket lain
|
{canGetAnotherTicket
|
||||||
|
? "Dapatkan nombor giliran lain"
|
||||||
|
: `Tunggu ${cooldownSeconds}s…`}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -22,5 +22,11 @@
|
|||||||
"eslint-config-next": "16.2.10",
|
"eslint-config-next": "16.2.10",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"sharp",
|
||||||
|
"unrs-resolver"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
ignoredBuiltDependencies:
|
|
||||||
- sharp
|
|
||||||
- unrs-resolver
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
services:
|
||||||
|
admin:
|
||||||
|
image: ${ADMIN_IMAGE:-qms-fe-admin}:${IMAGE_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: ./admin-app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:8080}
|
||||||
|
VITE_BRANCH_QR_BASE_URL: ${VITE_BRANCH_QR_BASE_URL:-http://localhost:3000}
|
||||||
|
container_name: qms-fe-admin
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${ADMIN_HOST_PORT:-5000}:80"
|
||||||
|
|
||||||
|
teller:
|
||||||
|
image: ${TELLER_IMAGE:-qms-fe-teller}:${IMAGE_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: ./teller-app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:8080}
|
||||||
|
VITE_GOLD_PRICE_URL: ${VITE_GOLD_PRICE_URL:-https://apiujrah.erahn.com.my/api/harga_emas}
|
||||||
|
container_name: qms-fe-teller
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${TELLER_HOST_PORT:-3001}:80"
|
||||||
|
|
||||||
|
customer:
|
||||||
|
image: ${CUSTOMER_IMAGE:-qms-fe-customer}:${IMAGE_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: ./customer-app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
||||||
|
container_name: qms-fe-customer
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${CUSTOMER_HOST_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
QR_TOKEN_SECRET: ${QR_TOKEN_SECRET:-qms-branch-qr-v1}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
FROM node:21-alpine AS build
|
# Build stage
|
||||||
WORKDIR /teller-app
|
FROM node:20-bookworm AS build
|
||||||
COPY package*.json .
|
WORKDIR /app
|
||||||
RUN npm install
|
|
||||||
|
ARG VITE_API_URL=http://localhost:8080
|
||||||
|
ARG VITE_GOLD_PRICE_URL=https://apiujrah.erahn.com.my/api/harga_emas
|
||||||
|
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_GOLD_PRICE_URL=$VITE_GOLD_PRICE_URL
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3000
|
# Runtime stage — static SPA
|
||||||
CMD ["npm", "run", "preview"]
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
export const SERVER_URL = 'http://localhost:8080';
|
export const SERVER_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
export const GOLD_PRICE_URL =
|
export const GOLD_PRICE_URL = import.meta.env.VITE_GOLD_PRICE_URL || 'https://apiujrah.erahn.com.my/api/harga_emas';
|
||||||
import.meta.env.VITE_GOLD_PRICE_URL ??
|
|
||||||
'https://apiujrah.erahn.com.my/api/harga_emas';
|
|
||||||
|
|
||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
ROLE_USER: 'ROLE_USER',
|
ROLE_USER: 'ROLE_USER',
|
||||||
|
|||||||
@@ -76,22 +76,41 @@ export default function AdCarousel({ tenantCode }) {
|
|||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
|
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
|
||||||
|
|
||||||
|
let removeUnmuteListener = () => {};
|
||||||
|
|
||||||
const onEnded = () => goNext();
|
const onEnded = () => goNext();
|
||||||
video.addEventListener('ended', onEnded);
|
const scheduleFallbackAdvance = () => {
|
||||||
video.muted = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
const playPromise = video.play();
|
|
||||||
if (playPromise?.catch) {
|
|
||||||
playPromise.catch(() => {
|
|
||||||
// Autoplay blocked — advance after durationSeconds fallback
|
|
||||||
clearTimer();
|
clearTimer();
|
||||||
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
|
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
|
||||||
timerRef.current = setTimeout(goNext, durationMs);
|
timerRef.current = setTimeout(goNext, durationMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('ended', onEnded);
|
||||||
|
video.muted = false;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
|
const playPromise = video.play();
|
||||||
|
if (playPromise?.catch) {
|
||||||
|
playPromise.catch(() => {
|
||||||
|
// Unmuted autoplay blocked — keep video playing muted, then unmute on gesture.
|
||||||
|
video.muted = true;
|
||||||
|
const mutedPlay = video.play();
|
||||||
|
if (mutedPlay?.catch) {
|
||||||
|
mutedPlay.catch(scheduleFallbackAdvance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmute = () => {
|
||||||
|
video.muted = false;
|
||||||
|
video.play()?.catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', unmute, { once: true });
|
||||||
|
removeUnmuteListener = () => window.removeEventListener('pointerdown', unmute);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('ended', onEnded);
|
video.removeEventListener('ended', onEnded);
|
||||||
|
removeUnmuteListener();
|
||||||
};
|
};
|
||||||
}, [currentAd, goNext, clearTimer]);
|
}, [currentAd, goNext, clearTimer]);
|
||||||
|
|
||||||
@@ -127,7 +146,6 @@ export default function AdCarousel({ tenantCode }) {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="branch-display__ad-media"
|
className="branch-display__ad-media"
|
||||||
src={src}
|
src={src}
|
||||||
muted
|
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay
|
autoPlay
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,11 +15,15 @@
|
|||||||
--bd-error-bg: #5c1a1a;
|
--bd-error-bg: #5c1a1a;
|
||||||
--bd-error-text: #fecaca;
|
--bd-error-text: #fecaca;
|
||||||
|
|
||||||
|
/* Fluid root: scales 720p → 4K; descendants use em so they follow */
|
||||||
|
font-size: clamp(12px, 0.75vw + 0.55vh, 28px);
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1rem 1.5rem 1rem;
|
padding: 0.75em 1em;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
|
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
|
||||||
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
|
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
|
||||||
@@ -28,54 +32,37 @@
|
|||||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
gap: 0.75rem;
|
gap: 0.6em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__sound-enable {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.75rem;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 20;
|
|
||||||
border: 1px solid var(--bd-accent-deep);
|
|
||||||
background: linear-gradient(180deg, var(--bd-accent-bright) 0%, var(--bd-accent) 100%);
|
|
||||||
color: #1a2e14;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 12px rgba(240, 208, 96, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.branch-display__header {
|
.branch-display__header {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__brand {
|
.branch-display__brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.85rem;
|
gap: 0.85em;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__logo {
|
.branch-display__logo {
|
||||||
height: clamp(2.5rem, 4.5vw, 3.5rem);
|
height: clamp(2em, 1.8em + 1vw, 4em);
|
||||||
width: auto;
|
width: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 0.35rem;
|
border-radius: 0.35em;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__eyebrow {
|
.branch-display__eyebrow {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75em;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--bd-accent);
|
color: var(--bd-accent);
|
||||||
@@ -84,7 +71,7 @@
|
|||||||
|
|
||||||
.branch-display__title {
|
.branch-display__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
font-size: clamp(1.25em, 2vw, 1.75em);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: var(--bd-text);
|
color: var(--bd-text);
|
||||||
@@ -93,17 +80,17 @@
|
|||||||
|
|
||||||
.branch-display__error {
|
.branch-display__error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5em 0.75em;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4em;
|
||||||
background: var(--bd-error-bg);
|
background: var(--bd-error-bg);
|
||||||
color: var(--bd-error-text);
|
color: var(--bd-error-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__main {
|
.branch-display__main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
|
||||||
gap: 0.85rem;
|
gap: 0.85em;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -111,6 +98,7 @@
|
|||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.branch-display {
|
.branch-display {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
min-height: 100dvh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
@@ -127,7 +115,7 @@
|
|||||||
|
|
||||||
.branch-display__section--gold {
|
.branch-display__section--gold {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: 16rem;
|
min-height: 16em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +124,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.75em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,12 +133,23 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Serving block must keep its content height — otherwise cards spill into Giliran */
|
||||||
|
.branch-display__queue-column > .branch-display__section:first-of-type {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.branch-display__section--waiting {
|
.branch-display__section--waiting {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
max-height: 28%;
|
max-height: 28%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__section--gold {
|
.branch-display__section--gold {
|
||||||
@@ -158,11 +157,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__section-title {
|
.branch-display__section-title {
|
||||||
margin: 0 0 0.45rem;
|
margin: 0 0 0.45em;
|
||||||
font-size: 0.85rem;
|
font-size: 1.05em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -171,16 +171,16 @@
|
|||||||
|
|
||||||
.branch-display__stations {
|
.branch-display__stations {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.7em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__station-card {
|
.branch-display__station-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.35em;
|
||||||
padding: 0.65rem 0.5rem;
|
padding: 0.9em 0.65em;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5em;
|
||||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||||
border: 1px solid var(--bd-border);
|
border: 1px solid var(--bd-border);
|
||||||
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
|
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
|
||||||
@@ -189,18 +189,51 @@
|
|||||||
|
|
||||||
.branch-display__station-name {
|
.branch-display__station-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
padding: 0.2em 0.45em;
|
||||||
color: var(--bd-muted);
|
font-size: clamp(1.35em, min(3.2vw, 5.5vh), 2.75em);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--bd-text);
|
||||||
|
background: rgba(7, 26, 16, 0.45);
|
||||||
|
border-radius: 0.25em;
|
||||||
|
border: 1px solid var(--bd-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__ticket-number {
|
.branch-display__ticket-number {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.75rem, 3.2vw, 2.75rem);
|
font-size: clamp(1.75em, min(4.2vw, 8vh), 5.5em);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
color: var(--bd-accent-bright);
|
color: var(--bd-accent-bright);
|
||||||
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
|
text-shadow: 0 0 1.5em rgba(255, 229, 102, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Short TVs (e.g. 720p): tighten serving cards so sections don't collide */
|
||||||
|
@media (max-height: 800px) {
|
||||||
|
.branch-display__station-card {
|
||||||
|
padding: 0.55em 0.5em;
|
||||||
|
gap: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-display__station-name {
|
||||||
|
font-size: clamp(1.15em, min(2.6vw, 4.5vh), 2em);
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-display__ticket-number {
|
||||||
|
font-size: clamp(1.5em, min(3.6vw, 7vh), 3.25em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-display__section-title {
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-display__section--waiting {
|
||||||
|
max-height: 24%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__ticket-number--idle {
|
.branch-display__ticket-number--idle {
|
||||||
@@ -210,7 +243,7 @@
|
|||||||
|
|
||||||
.branch-display__station-service {
|
.branch-display__station-service {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 1em;
|
||||||
color: var(--bd-muted-strong);
|
color: var(--bd-muted-strong);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -221,8 +254,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75em;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,14 +265,14 @@
|
|||||||
|
|
||||||
.branch-display__waiting-count {
|
.branch-display__waiting-count {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 1.05em;
|
||||||
color: var(--bd-accent);
|
color: var(--bd-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__empty {
|
.branch-display__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--bd-muted);
|
color: var(--bd-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 1.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__waiting-list {
|
.branch-display__waiting-list {
|
||||||
@@ -248,7 +281,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.45em;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -258,24 +291,24 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75em;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.55em 0.85em;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4em;
|
||||||
background: var(--bd-surface);
|
background: var(--bd-surface);
|
||||||
border: 1px solid var(--bd-border);
|
border: 1px solid var(--bd-border);
|
||||||
border-left: 3px solid var(--bd-green);
|
border-left: 0.2em solid var(--bd-green);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__waiting-number {
|
.branch-display__waiting-number {
|
||||||
font-size: clamp(1.1rem, 1.8vw, 1.5rem);
|
font-size: clamp(1.35em, 2.4vw, 2.5em);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: var(--bd-accent);
|
color: var(--bd-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__waiting-service {
|
.branch-display__waiting-service {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85em;
|
||||||
color: var(--bd-muted-strong);
|
color: var(--bd-muted-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,8 +316,8 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0.65rem;
|
padding: 0.65em;
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6em;
|
||||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||||
border: 1px solid var(--bd-border);
|
border: 1px solid var(--bd-border);
|
||||||
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
|
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
|
||||||
@@ -297,8 +330,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75em;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +344,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bd-bg);
|
background: var(--bd-bg);
|
||||||
border: 1px solid var(--bd-border);
|
border: 1px solid var(--bd-border);
|
||||||
@@ -326,8 +359,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__ad-caption {
|
.branch-display__ad-caption {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35em 0 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8em;
|
||||||
color: var(--bd-muted-strong);
|
color: var(--bd-muted-strong);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -337,7 +370,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__section--gold .branch-display__waiting-header {
|
.branch-display__section--gold .branch-display__waiting-header {
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__gold-table-wrap {
|
.branch-display__gold-table-wrap {
|
||||||
@@ -345,7 +378,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5em;
|
||||||
border: 1px solid var(--bd-border);
|
border: 1px solid var(--bd-border);
|
||||||
background: var(--bd-surface);
|
background: var(--bd-surface);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -358,19 +391,19 @@
|
|||||||
.branch-display__gold-table {
|
.branch-display__gold-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9rem;
|
font-size: 1.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__gold-table th,
|
.branch-display__gold-table th,
|
||||||
.branch-display__gold-table td {
|
.branch-display__gold-table td {
|
||||||
padding: 0.45rem 0.65rem;
|
padding: 0.55em 0.75em;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--bd-border);
|
border-bottom: 1px solid var(--bd-border);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-display__gold-table th {
|
.branch-display__gold-table th {
|
||||||
font-size: 0.7rem;
|
font-size: 0.72em;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--bd-accent);
|
color: var(--bd-accent);
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { fetchData } from '../../fetching/Fetch.js';
|
import { fetchData } from '../../fetching/Fetch.js';
|
||||||
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
|
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
|
||||||
import {
|
import {
|
||||||
hasNewServingCall,
|
getNewServingCalls,
|
||||||
playQueueCallSound,
|
playQueueCallSound,
|
||||||
servingSnapshot,
|
servingSnapshot,
|
||||||
unlockQueueCallSound,
|
unlockQueueCallSound,
|
||||||
} from '../../utils/queueCallSound.js';
|
} from '../../utils/queueCallSound.js';
|
||||||
|
import {
|
||||||
|
announceQueueCalls,
|
||||||
|
cancelQueueSpeech,
|
||||||
|
unlockQueueSpeech,
|
||||||
|
} from '../../utils/queueCallSpeech.js';
|
||||||
import AdCarousel from './AdCarousel.jsx';
|
import AdCarousel from './AdCarousel.jsx';
|
||||||
import './BranchDisplayPage.css';
|
import './BranchDisplayPage.css';
|
||||||
|
|
||||||
@@ -29,7 +34,6 @@ export default function BranchDisplayPage() {
|
|||||||
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
|
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
|
||||||
const [goldError, setGoldError] = useState(null);
|
const [goldError, setGoldError] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [soundReady, setSoundReady] = useState(false);
|
|
||||||
const goldTableWrapRef = useRef(null);
|
const goldTableWrapRef = useRef(null);
|
||||||
const previousServingRef = useRef(null);
|
const previousServingRef = useRef(null);
|
||||||
|
|
||||||
@@ -173,14 +177,38 @@ export default function BranchDisplayPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = servingSnapshot(tickets);
|
const next = servingSnapshot(tickets);
|
||||||
const previous = previousServingRef.current;
|
const previous = previousServingRef.current;
|
||||||
|
const newCalls = getNewServingCalls(previous, tickets);
|
||||||
|
|
||||||
if (hasNewServingCall(previous, next)) {
|
if (newCalls.length > 0) {
|
||||||
playQueueCallSound();
|
playQueueCallSound({
|
||||||
|
onEnded: () => announceQueueCalls(newCalls),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
previousServingRef.current = next;
|
previousServingRef.current = next;
|
||||||
}, [tickets]);
|
}, [tickets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.speechSynthesis) return undefined;
|
||||||
|
|
||||||
|
const loadVoices = () => {
|
||||||
|
window.speechSynthesis.getVoices();
|
||||||
|
};
|
||||||
|
|
||||||
|
loadVoices();
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', loadVoices);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', loadVoices);
|
||||||
|
cancelQueueSpeech();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unlockAnnouncements = useCallback(() => {
|
||||||
|
unlockQueueCallSound();
|
||||||
|
unlockQueueSpeech();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const waiting = useMemo(
|
const waiting = useMemo(
|
||||||
() => tickets.filter((ticket) => ticket.station == null),
|
() => tickets.filter((ticket) => ticket.station == null),
|
||||||
[tickets]
|
[tickets]
|
||||||
@@ -192,29 +220,7 @@ export default function BranchDisplayPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="branch-display" onClick={unlockAnnouncements}>
|
||||||
className="branch-display"
|
|
||||||
onClick={() => {
|
|
||||||
if (!soundReady) {
|
|
||||||
unlockQueueCallSound();
|
|
||||||
setSoundReady(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!soundReady ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="branch-display__sound-enable"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
unlockQueueCallSound();
|
|
||||||
setSoundReady(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Enable call sound
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<header className="branch-display__header">
|
<header className="branch-display__header">
|
||||||
<div className="branch-display__brand">
|
<div className="branch-display__brand">
|
||||||
<img
|
<img
|
||||||
@@ -246,9 +252,6 @@ export default function BranchDisplayPage() {
|
|||||||
key={station.id}
|
key={station.id}
|
||||||
className="branch-display__station-card"
|
className="branch-display__station-card"
|
||||||
>
|
>
|
||||||
<p className="branch-display__station-name">
|
|
||||||
{station.name}
|
|
||||||
</p>
|
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
ticket
|
ticket
|
||||||
@@ -258,8 +261,11 @@ export default function BranchDisplayPage() {
|
|||||||
>
|
>
|
||||||
{ticket ? ticket.number : '—'}
|
{ticket ? ticket.number : '—'}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="branch-display__station-name">
|
||||||
|
{station.name}
|
||||||
|
</p>
|
||||||
<p className="branch-display__station-service">
|
<p className="branch-display__station-service">
|
||||||
{ticket?.service?.name ?? 'Waiting for next'}
|
{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,20 +45,35 @@ export function unlockQueueCallSound() {
|
|||||||
/**
|
/**
|
||||||
* Play the queue-call notification chime.
|
* Play the queue-call notification chime.
|
||||||
* Safe to call repeatedly; restarts from the beginning each time.
|
* Safe to call repeatedly; restarts from the beginning each time.
|
||||||
|
* @param {{ onEnded?: () => void }} [options]
|
||||||
*/
|
*/
|
||||||
export function playQueueCallSound() {
|
export function playQueueCallSound({ onEnded } = {}) {
|
||||||
|
let endedNotified = false;
|
||||||
|
const notifyEnded = () => {
|
||||||
|
if (endedNotified || !onEnded) return;
|
||||||
|
endedNotified = true;
|
||||||
|
onEnded();
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audio = getCallAudio();
|
const audio = getCallAudio();
|
||||||
audio.muted = false;
|
audio.muted = false;
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
|
|
||||||
|
if (onEnded) {
|
||||||
|
audio.addEventListener('ended', notifyEnded, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
const playPromise = audio.play();
|
const playPromise = audio.play();
|
||||||
if (playPromise?.catch) {
|
if (playPromise?.catch) {
|
||||||
playPromise.catch((error) => {
|
playPromise.catch((error) => {
|
||||||
console.warn('Could not play queue call sound:', error);
|
console.warn('Could not play queue call sound:', error);
|
||||||
|
notifyEnded();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not play queue call sound:', error);
|
console.warn('Could not play queue call sound:', error);
|
||||||
|
notifyEnded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +94,37 @@ export function servingSnapshot(tickets) {
|
|||||||
* Returns true if any station got a new/different ticket number vs the previous snapshot.
|
* Returns true if any station got a new/different ticket number vs the previous snapshot.
|
||||||
*/
|
*/
|
||||||
export function hasNewServingCall(previous, next) {
|
export function hasNewServingCall(previous, next) {
|
||||||
if (!previous) return false;
|
return getNewServingCalls(previous, next).length > 0;
|
||||||
for (const [stationId, number] of Object.entries(next)) {
|
|
||||||
if (previous[stationId] !== number) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns stations whose assigned ticket number changed since the previous snapshot.
|
||||||
|
* Pass tickets when station names are needed for voice announcements.
|
||||||
|
*/
|
||||||
|
export function getNewServingCalls(previous, nextOrTickets) {
|
||||||
|
if (!previous) return [];
|
||||||
|
|
||||||
|
const tickets = Array.isArray(nextOrTickets)
|
||||||
|
? nextOrTickets
|
||||||
|
: Object.entries(nextOrTickets).map(([stationId, number]) => ({
|
||||||
|
station: { id: stationId },
|
||||||
|
number,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
if (ticket?.station?.id == null || ticket?.number == null) continue;
|
||||||
|
|
||||||
|
const stationId = String(ticket.station.id);
|
||||||
|
const number = String(ticket.number);
|
||||||
|
if (previous[stationId] === number) continue;
|
||||||
|
|
||||||
|
calls.push({
|
||||||
|
stationId,
|
||||||
|
ticketNumber: number,
|
||||||
|
stationName: ticket.station.name ?? `Stesen ${stationId}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
return calls;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const MALAY_LANG = 'ms-MY';
|
||||||
|
|
||||||
|
const MALAY_DIGITS = [
|
||||||
|
'kosong',
|
||||||
|
'satu',
|
||||||
|
'dua',
|
||||||
|
'tiga',
|
||||||
|
'empat',
|
||||||
|
'lima',
|
||||||
|
'enam',
|
||||||
|
'tujuh',
|
||||||
|
'lapan',
|
||||||
|
'sembilan',
|
||||||
|
];
|
||||||
|
|
||||||
|
let speechUnlocked = false;
|
||||||
|
let speechQueue = [];
|
||||||
|
let speaking = false;
|
||||||
|
|
||||||
|
function speechSynthesisAvailable() {
|
||||||
|
return typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVoices() {
|
||||||
|
if (!speechSynthesisAvailable()) return [];
|
||||||
|
return window.speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMalayVoice() {
|
||||||
|
const voices = loadVoices();
|
||||||
|
return (
|
||||||
|
voices.find((voice) => voice.lang === MALAY_LANG) ||
|
||||||
|
voices.find((voice) => voice.lang.startsWith('ms')) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spell ticket numbers digit-by-digit in Malay for clearer announcements.
|
||||||
|
*/
|
||||||
|
export function formatTicketNumberForSpeech(ticketNumber) {
|
||||||
|
return String(ticketNumber)
|
||||||
|
.trim()
|
||||||
|
.split('')
|
||||||
|
.map((character) => {
|
||||||
|
if (/\d/.test(character)) {
|
||||||
|
return MALAY_DIGITS[Number(character)];
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMalayAnnouncement(ticketNumber, stationName) {
|
||||||
|
const spokenNumber = formatTicketNumberForSpeech(ticketNumber);
|
||||||
|
return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call once after a user gesture so later speech is allowed on strict browsers.
|
||||||
|
*/
|
||||||
|
export function unlockQueueSpeech() {
|
||||||
|
if (speechUnlocked || !speechSynthesisAvailable()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadVoices();
|
||||||
|
const utterance = new SpeechSynthesisUtterance('');
|
||||||
|
utterance.volume = 0;
|
||||||
|
utterance.lang = MALAY_LANG;
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
} catch {
|
||||||
|
// Ignore unlock failures; real announcements will still be attempted.
|
||||||
|
} finally {
|
||||||
|
speechUnlocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSpeechQueue() {
|
||||||
|
if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return;
|
||||||
|
|
||||||
|
speaking = true;
|
||||||
|
const text = speechQueue.shift();
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utterance.lang = MALAY_LANG;
|
||||||
|
|
||||||
|
const voice = pickMalayVoice();
|
||||||
|
if (voice) {
|
||||||
|
utterance.voice = voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.rate = 0.75;
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
speaking = false;
|
||||||
|
processSpeechQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
utterance.onend = finish;
|
||||||
|
utterance.onerror = () => {
|
||||||
|
console.warn('Could not speak queue announcement:', text);
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue Malay voice announcements for one or more station calls.
|
||||||
|
* @param {{ ticketNumber: string, stationName: string }[]} calls
|
||||||
|
*/
|
||||||
|
export function announceQueueCalls(calls) {
|
||||||
|
if (!calls?.length || !speechSynthesisAvailable()) return;
|
||||||
|
|
||||||
|
unlockQueueSpeech();
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
speechQueue.push(
|
||||||
|
formatMalayAnnouncement(call.ticketNumber, call.stationName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
processSpeechQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelQueueSpeech() {
|
||||||
|
if (!speechSynthesisAvailable()) return;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
speechQueue = [];
|
||||||
|
speaking = false;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user