Compare commits

..
239 changed files with 3592 additions and 12276 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Qodana Configuration Upload
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+3 -7
View File
@@ -7,13 +7,9 @@ on:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
upload-qodana-config: upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -24,9 +20,9 @@ jobs:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }} QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: | run: |
docker run --rm \ docker run --rm \
-v "$(pwd):/workspace" \ -v $(pwd):/workspace \
-w /workspace \ -w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN \ -e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \ jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \ --global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud --qodana-host https://qodana.cloud
+14 -83
View File
@@ -1,35 +1,30 @@
name: Frontend Release name: Frontend Release
on: on:
workflow_run: push:
workflows:
- Automated Tests
types:
- completed
branches: branches:
- master - master
workflow_dispatch:
permissions: permissions:
contents: read contents: read
actions: read actions: read
concurrency: concurrency:
group: frontend-release-${{ github.event.workflow_run.head_branch }} group: frontend-release-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: false
jobs: jobs:
build-upload-and-verify: build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, default]
env: env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }} RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45 RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600 RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10 RELEASE_POLL_INTERVAL_SECONDS: 10
@@ -38,114 +33,54 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
- name: Setup Node.js - name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
if: steps.branch-head.outputs.current == 'true'
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Check AI workflow sync - name: Check AI workflow sync
if: steps.branch-head.outputs.current == 'true'
run: node scripts/sync-ai-workflow.mjs --check run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks - name: Source and i18n checks
if: steps.branch-head.outputs.current == 'true'
run: | run: |
npm run text:check-encoding npm run text:check-encoding
npm run i18n:v2:source-check npm run i18n:v2:source-check
- name: Unit tests - name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit run: npm run test:unit
env: env:
VITEST_BATCH_SIZE: 5 VITEST_BATCH_SIZE: 5
- name: Build release artifact - name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build run: npm run build
- name: Install Playwright Chromium - name: Install Playwright Chromium
if: steps.branch-head.outputs.current == 'true' run: npx playwright install --with-deps chromium
run: node scripts/install-playwright-browsers.mjs chromium
- name: Production Playwright gate - name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod run: npm run test:e2e:prod
- name: Upload dist artifact - name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }} name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist path: dist
retention-days: 3 retention-days: 14
- name: Request Release Manager auto sync
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
status_code="$(curl --show-error --silent \
--output "$response_file" \
--write-out "%{http_code}" \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Wait for Coolify release artifact - name: Wait for Coolify release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run release:verify-upload run: npm run release:verify-upload
- name: Public live Playwright gate - name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:public run: npm run test:e2e:live:public
env: env:
NODE_OPTIONS: --use-system-ca NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate - name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:roles run: npm run test:e2e:live:roles
env: env:
NODE_OPTIONS: --use-system-ca NODE_OPTIONS: --use-system-ca
@@ -158,35 +93,31 @@ jobs:
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }} PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate - name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: | run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
curl --fail --show-error --silent \ curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \ -X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}" --data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env: env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }} RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification - name: Update server version after verification
if: steps.branch-head.outputs.current == 'true'
run: npm run release:update-server-version run: npm run release:update-server-version
env: env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }} SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }} RELEASE_VERSION: ${{ github.sha }}
- name: Upload Playwright report - name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true' if: failure()
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }} name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright path: output/playwright
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 14
+15 -109
View File
@@ -18,7 +18,6 @@ jobs:
format-tests: format-tests:
# CI runs on the repository's self-hosted runner pool. # CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -41,7 +40,6 @@ jobs:
build-and-unit: build-and-unit:
needs: format-tests needs: format-tests
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -55,12 +53,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Check i18n source consistency
run: npm run i18n:v2:check
- name: Build sanity check - name: Build sanity check
run: npm run build run: npm run build
@@ -72,18 +64,7 @@ jobs:
e2e-pr: e2e-pr:
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
needs: build-and-unit needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }} runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64]
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -123,70 +104,27 @@ jobs:
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Install Playwright browsers - name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs chromium run: npx playwright install --with-deps chromium
- name: Set Playwright dev server port - name: Run Playwright PR tests
shell: bash run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
env:
MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }}
run: |
set -euo pipefail
case "$MATRIX_SUITE" in
core) suite_offset=0 ;;
changed) suite_offset=10 ;;
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
esac
case "$MATRIX_PROJECT" in
chromium-desktop) project_offset=1 ;;
chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((5200 + suite_offset + project_offset))" >> "$GITHUB_ENV"
- name: Run Playwright smoke tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Run Playwright PR core tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}"
- name: Run Playwright changed-area tests
if: matrix.suite == 'changed'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report - name: Upload Playwright report
if: failure() || cancelled() if: failure()
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }} name: playwright-report-pr
path: | path: output/playwright
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 7
e2e-full: e2e-full:
if: > if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
always() && needs: build-and-unit
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 4
matrix: matrix:
role: [customer, subuser, admin, superuser] role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit] browser: [chromium, firefox, webkit]
@@ -218,45 +156,13 @@ jobs:
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Install Playwright browsers - name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }} run: npx playwright install --with-deps ${{ matrix.browser_install }}
- name: Set Playwright dev server port
shell: bash
env:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
run: |
set -euo pipefail
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((5300 + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
- name: Run full Playwright slice - name: Run full Playwright slice
run: | run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Upload Playwright report - name: Upload Playwright report
if: failure() || cancelled() if: failure()
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }} name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
@@ -265,4 +171,4 @@ jobs:
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 14
-4
View File
@@ -1,4 +0,0 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig { defaultConfig {
applicationId "io.truckwash.twa" applicationId "io.truckwash.twa"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 36 targetSdkVersion 35
versionCode 7 versionCode 6
versionName "7" versionName "6"
// The name for the application // The name for the application
resValue "string", "appName", twaManifest.name resValue "string", "appName", twaManifest.name
+1 -1
View File
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"} {"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
@@ -1,33 +0,0 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
-139
View File
@@ -1,139 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import vue from "eslint-plugin-vue";
const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
grecaptcha: "readonly",
};
const vitestGlobals = {
...globals.vitest,
};
const lintTargets = ["**/*.{js,mjs,cjs,ts,tsx,vue}"];
const tsTargets = ["**/*.{ts,tsx,mts,cts}"];
function warningRules(rules = {}) {
return Object.fromEntries(
Object.entries(rules).map(([name, value]) => {
if (value === "off" || value === 0) {
return [name, value];
}
if (Array.isArray(value)) {
return [name, ["warn", ...value.slice(1)]];
}
return [name, "warn"];
}),
);
}
function warningConfig(config, files) {
return {
...config,
files: config.files ?? files,
rules: warningRules(config.rules),
};
}
const jsRecommended = warningConfig(js.configs.recommended, lintTargets);
const tsRecommended = tseslint.configs.recommended.map((config) =>
warningConfig(config, tsTargets),
);
const vueEssential = vue.configs["flat/essential"].map((config) =>
warningConfig(config, ["**/*.vue"]),
);
const ignoredPaths = [
"app/build/**",
"coverage/**",
"dist/**",
"node_modules/**",
"node_modules.codex-backup/**",
"output/**",
"public/build/**",
"src/assets/test/**",
"test/**",
"vendor/**",
"*.timestamp-*.mjs",
"**/*.timestamp-*.mjs",
];
const commonUnusedOptions = {
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
varsIgnorePattern: "^_",
};
export default [
{
ignores: ignoredPaths,
},
jsRecommended,
...tsRecommended,
...vueEssential,
{
files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: [".vue"],
ecmaVersion: "latest",
sourceType: "module",
},
},
},
{
files: lintTargets,
plugins: {
vue,
},
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: commonGlobals,
},
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", commonUnusedOptions],
},
},
{
files: ["tests/**/*.{js,ts}", "playwright*.ts", "scripts/**/*.{js,mjs}"],
languageOptions: {
globals: {
...commonGlobals,
...globals.node,
...vitestGlobals,
},
},
rules: {
"no-empty": "warn",
},
},
];
+4 -13
View File
@@ -2,19 +2,10 @@
<html lang="" class="theme-light" data-theme="light"> <html lang="" class="theme-light" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script> <link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
(function () { <link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/); <link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/'; <link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" /> <meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
+1 -9
View File
@@ -8,14 +8,6 @@ server {
add_header Content-Security-Policy "frame-ancestors 'self'" always; add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
types { application/manifest+json webmanifest; }
default_type application/manifest+json;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$webmanifest_path =404;
}
location ~ ^/(release-entry|release-manifest)\.json$ { location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always; add_header Content-Security-Policy "frame-ancestors 'self'" always;
@@ -37,7 +29,7 @@ server {
try_files /$static_asset_path =404; try_files /$static_asset_path =404;
} }
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ { location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404; try_files /$static_file_path =404;
} }
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig { defaultConfig {
applicationId "io.truckwash.twa.staging" applicationId "io.truckwash.twa.staging"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 36 targetSdkVersion 35
versionCode 6 versionCode 5
versionName "6" versionName "5"
// The name for the application // The name for the application
resValue "string", "appName", twaManifest.name resValue "string", "appName", twaManifest.name
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"} {"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
+35 -1114
View File
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -7,9 +7,6 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
"lint:strict": "npm run lint:report -- --max-warnings=0",
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"", "format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs", "format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"", "format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
@@ -89,6 +86,7 @@
"dayspan-vuetify": "^0.4.0", "dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3", "flow": "^0.2.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sass": "^1.81.1", "sass": "^1.81.1",
"sweetalert2": "11.22.4", "sweetalert2": "11.22.4",
@@ -111,23 +109,17 @@
}, },
"devDependencies": { "devDependencies": {
"@creativebulma/bulma-divider": "^1.1.0", "@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0", "@event-calendar/core": "^4.1.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0", "@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5", "@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass-embedded": "^1.81.0", "sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11", "vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2", "vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4", "vite-plugin-vue-devtools": "^7.5.4",
-4
View File
@@ -38,14 +38,10 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/release/**"], testIgnore: ["**/release/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000, timeout: 60_000,
fullyParallel: true, fullyParallel: true,
forbidOnly: isCI, forbidOnly: isCI,
retries: isCI ? 2 : 0, retries: isCI ? 2 : 0,
expect: {
timeout: 15_000,
},
workers, workers,
...(process.env.PLAYWRIGHT_BASE_URL ...(process.env.PLAYWRIGHT_BASE_URL
? {} ? {}
+4 -11
View File
@@ -19,16 +19,6 @@ const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js"); const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
const serverOutputLimit = 80; const serverOutputLimit = 80;
const activeOutputReaders = []; const activeOutputReaders = [];
const viteServerEnv = {
...process.env,
PLAYWRIGHT: "1",
...(process.env.CI
? {
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
}
: {}),
};
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot. // Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [ const viteDevArgs = [
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []), ...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
@@ -459,7 +449,10 @@ export default async function globalSetup() {
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], { const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
cwd: process.cwd(), cwd: process.cwd(),
detached: true, detached: true,
env: viteServerEnv, env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}); });
+20 -51
View File
@@ -1,4 +1,3 @@
import fs from "node:fs";
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173"; const baseURL = "http://127.0.0.1:4173";
@@ -6,55 +5,6 @@ const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL; process.env.PLAYWRIGHT_BASE_URL = baseURL;
const osReleaseValue = (key: string) => {
try {
const body = fs.readFileSync("/etc/os-release", "utf8");
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
return String(match?.[1] || "").replace(/^"|"$/g, "");
} catch {
return "";
}
};
const isUnsupportedWebKitHost = () => {
if (process.platform !== "linux") {
return false;
}
const id = osReleaseValue("ID").toLowerCase();
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
};
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
.trim()
.toLowerCase();
const includeWebKit =
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
const projects = [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
];
if (includeWebKit) {
projects.push({
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
});
}
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e/release", testDir: "./tests/e2e/release",
testMatch: /.*\.local-prod\.spec\.ts/, testMatch: /.*\.local-prod\.spec\.ts/,
@@ -78,5 +28,24 @@ export default defineConfig({
timeout: 240_000, timeout: 240_000,
reuseExistingServer: !isCI, reuseExistingServer: !isCI,
}, },
projects, projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
},
],
}); });
+4 -8
View File
@@ -2,10 +2,6 @@
Options -MultiViews Options -MultiViews
</IfModule> </IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
@@ -27,17 +23,17 @@
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L] RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L] RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L] RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
@@ -45,7 +41,7 @@
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L] RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
-1
View File
@@ -2,7 +2,6 @@
"name": "Truck Wash Kundeportal", "name": "Truck Wash Kundeportal",
"short_name": "Truck Wash", "short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.", "description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [ "icons": [
{ {
"src": "assets/favicons/web-app-manifest-192x192.png", "src": "assets/favicons/web-app-manifest-192x192.png",
-90
View File
@@ -1,90 +0,0 @@
import { spawnSync } from "node:child_process";
const browsers = process.argv.slice(2);
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
return `ubuntu24.04-${ubuntuMatch[2]}`;
}
return null;
};
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
const unsupportedHostPlatform = (result) => {
const output = outputText(result);
return (
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
null
);
};
const runPlaywrightInstall = (args, env = {}) =>
spawnSync("npx", ["playwright", "install", ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
},
encoding: "utf8",
});
const writeOutput = (result) => {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
};
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
writeOutput(withDepsResult);
if (withDepsResult.status === 0) {
process.exit(0);
}
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1);
}
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
const fallbackHostPlatform = unsupportedPlatform
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
: null;
if (!fallbackHostPlatform) {
console.error(
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
"and this script does not have a safe browser archive fallback for it."
);
process.exit(withDepsResult.status ?? 1);
}
console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
});
writeOutput(fallbackResult);
process.exit(fallbackResult.status ?? 1);
+1 -25
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -8,24 +7,6 @@ function numberEnv(name, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback; return Number.isFinite(value) && value >= 0 ? value : fallback;
} }
function booleanEnv(name) {
return /^(1|true|yes)$/i.test(process.env[name] || "");
}
function appendGithubEnv(values) {
const envFile = process.env.GITHUB_ENV;
if (!envFile) {
return;
}
const lines = Object.entries(values)
.filter(([, value]) => value)
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
if (lines.length > 0) {
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
}
}
function requiredUrl() { function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL; const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) { if (!value) {
@@ -140,7 +121,6 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) { async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || ""; const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || ""; const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json"); const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json"); const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
@@ -150,7 +130,7 @@ async function verifyRelease(baseUrl) {
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) { if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`); throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
} }
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) { if (expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`); throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
} }
if (releaseEntry.entry !== manifest.entry) { if (releaseEntry.entry !== manifest.entry) {
@@ -211,10 +191,6 @@ async function main() {
attempt += 1; attempt += 1;
try { try {
const result = await verifyRelease(baseUrl); const result = await verifyRelease(baseUrl);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
});
console.log( console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}` `Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
); );
-2
View File
@@ -35,7 +35,6 @@ export const ownedFilesByRole = {
"userHome.spec.ts", "userHome.spec.ts",
"userInvoices.spec.ts", "userInvoices.spec.ts",
"userMyWashStart.spec.ts", "userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts", "userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts", "userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts", "userProfileSecurity.spec.ts",
@@ -80,7 +79,6 @@ export const ownedFilesByRole = {
"edge-gateways.visual.spec.js", "edge-gateways.visual.spec.js",
"errorReports.spec.ts", "errorReports.spec.ts",
"failover-config.source.spec.ts", "failover-config.source.spec.ts",
"collected-invoice-move-customer.spec.ts",
"invoice-distribution.smoke.spec.js", "invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts", "invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js", "invoice-transfer-queue-history.spec.js",
-16
View File
@@ -22,7 +22,6 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
function parseArgs(rawArgs) { function parseArgs(rawArgs) {
const parsed = { const parsed = {
help: false, help: false,
coreOnly: false,
changedOnly: false, changedOnly: false,
listOnly: false, listOnly: false,
base: "", base: "",
@@ -44,11 +43,6 @@ function parseArgs(rawArgs) {
continue; continue;
} }
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") { if (value === "--changed-only") {
parsed.changedOnly = true; parsed.changedOnly = true;
continue; continue;
@@ -109,14 +103,12 @@ Options:
--base <ref> Base ref for changed-area detection --base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD --head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> Restrict to one Chromium Playwright project. Can be repeated. --project <name> Restrict to one Chromium Playwright project. Can be repeated.
--core-only Run only the core @pr gate
--changed-only Run changed-area selection without the core @pr gate --changed-only Run changed-area selection without the core @pr gate
--list-only List selected tests instead of running them --list-only List selected tests instead of running them
-h, --help Show help -h, --help Show help
Examples: Examples:
npm run test:e2e:pr npm run test:e2e:pr
npm run test:e2e:pr -- --core-only --project=chromium-desktop
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
`); `);
} }
@@ -426,10 +418,6 @@ async function main() {
return; return;
} }
if (args.coreOnly && args.changedOnly) {
throw new Error("--core-only and --changed-only cannot be used together.");
}
await fs.access(playwrightCliPath); await fs.access(playwrightCliPath);
if (!args.changedOnly) { if (!args.changedOnly) {
@@ -440,10 +428,6 @@ async function main() {
} }
} }
if (args.coreOnly) {
return;
}
const changed = await getChangedFiles(); const changed = await getChangedFiles();
if (changed.unavailable) { if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`); console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
-2
View File
@@ -20,7 +20,6 @@ const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/L
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue")); const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue")); const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue")); const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() => const ReleaseChannelUnavailable = defineAsyncComponent(() =>
import("@/components/release/ReleaseChannelUnavailable.vue") import("@/components/release/ReleaseChannelUnavailable.vue")
); );
@@ -129,7 +128,6 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</template> </template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" /> <RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" /> <ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template> </template>
<style scoped></style> <style scoped></style>
@@ -2535,13 +2535,11 @@ const syncDesktopFlyoutPosition = () => {
dropdownContentEl.style.top = ""; dropdownContentEl.style.top = "";
} }
const viewportInsets = getViewportInsets(); const padding = 12;
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom; let top = triggerRect.bottom;
if (top + contentHeight > viewportBottom) { if (top + contentHeight > viewportHeight - padding) {
top = Math.max(viewportTop, triggerRect.top - contentHeight); top = Math.max(padding, triggerRect.top - contentHeight);
} }
const nextFixedStyles = { const nextFixedStyles = {
@@ -275,10 +275,6 @@ const getInvoiceCollectionResponseOrders = (response) => {
return response.data.orders; return response.data.orders;
} }
if (Array.isArray(response?.data?.data?.orders)) {
return response.data.data.orders;
}
if (Array.isArray(response?.includes?.orders)) { if (Array.isArray(response?.includes?.orders)) {
return response.includes.orders; return response.includes.orders;
} }
@@ -653,41 +649,6 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
} }
return null; return null;
}; };
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
const details = getInvoiceCollectionDetails(invoiceCollectionId);
return details ? getInvoiceCollectionResponseOrders(details) : [];
};
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
};
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
);
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
if (hiddenOrderIds.size === 0) {
return [];
}
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(
flag?.order_id
|| flag?.context?.order_id
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
);
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
}));
};
watch( watch(
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection], [() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
() => { () => {
@@ -1038,13 +999,6 @@ const formatCashierName = (order) => {
}} }}
er ikke vist, men vil muligvis blive faktureret alligevel.</span er ikke vist, men vil muligvis blive faktureret alligevel.</span
> >
<InvoicingPeriodFlagList
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
class="mt-3"
compact
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
@statusChanged="emitFlagStatusChanged"
/>
</div> </div>
</div> </div>
</td> </td>
@@ -21,7 +21,6 @@ import {
step, step,
setDesktopStep1PreflightHandler, setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler, clearDesktopStep1PreflightHandler,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue"; import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { POS_STEP_1_VERSION } from "@/config.js"; import { POS_STEP_1_VERSION } from "@/config.js";
@@ -646,7 +645,7 @@ const pushDesktopStepTwoRoute = () => {
return; return;
} }
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`); window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
}; };
const handleDesktopLastWashCopy = async (payload = {}) => { const handleDesktopLastWashCopy = async (payload = {}) => {
@@ -761,7 +760,7 @@ watch(
</div> </div>
<div class="pos-shell-actions"> <div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked"> <ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="5" class="is-fullwidth" /> <Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox> </ButtonsBox>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail"> <div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning <PosDesktopDuplicateWarning
@@ -10,7 +10,6 @@ import {
reg_1, reg_1,
reset_all_values, reset_all_values,
setStep, setStep,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue"; import { StripeModule } from "@/components/stripe/StripeModule.vue";
@@ -71,10 +70,10 @@ const formatCurrency = (amount) => {
const navigateToItems = () => { const navigateToItems = () => {
setStep(2); setStep(2);
if (normalizedOrderId.value) { if (normalizedOrderId.value) {
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`); window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return; return;
} }
pushPosRouteState("step=2"); window.history.pushState({}, '', `?step=2`);
}; };
const clearSuccessfulMobileCardFlow = () => { const clearSuccessfulMobileCardFlow = () => {
@@ -1,70 +0,0 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
retryTestId: null,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
:data-testid="retryTestId || undefined"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -1,59 +0,0 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BSkeleton } from "buefy";
const props = defineProps<{
src: string | null;
alt?: string;
}>();
const emit = defineEmits<{
(e: "error"): void;
(e: "load"): void;
}>();
const isLoading = ref(false);
watch(
() => props.src,
(src) => {
isLoading.value = !!src;
},
{ immediate: true }
);
const onLoad = () => {
isLoading.value = false;
emit("load");
};
const onError = () => {
isLoading.value = false;
emit("error");
};
</script>
<template>
<div
v-if="src"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="src"
:src="src"
:alt="alt || 'Machine status'"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isLoading }"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import { BIcon, BMessage, BRadioButton } from "buefy"; import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{ const props = defineProps<{
lanes: Array<any>; lanes: Array<any>;
selectedLaneId: number | string | null; selectedLaneId: number | string | null;
washType: string; washType: string;
@@ -14,6 +15,8 @@ const emit = defineEmits<{
(e: "update:selectedLaneId", value: number): void; (e: "update:selectedLaneId", value: number): void;
(e: "update:washType", value: string): void; (e: "update:washType", value: string): void;
}>(); }>();
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
</script> </script>
<template> <template>
@@ -92,14 +95,16 @@ const emit = defineEmits<{
:model-value="washType" :model-value="washType"
native-value="Machine" native-value="Machine"
type="is-link" type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)" :disabled="!isSelectedMachineAvailable"
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
data-testid="self-serve-wash-type-machine" data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')" @update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')" @input="emit('update:washType', 'Machine')"
> >
<span> <span>
<span>{{ $t("self_wash.machine") }}<br /></span> <span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable(selectedLaneId)"> <span v-if="!isSelectedMachineAvailable">
<small> <small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" /> <b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }} {{ $t("self_wash.unavailable") }}
@@ -21,6 +21,16 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
); );
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => { const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) { if (!Array.isArray(task?.attachments)) {
return []; return [];
@@ -73,7 +83,7 @@ const formatTaskTitle = (task: any) => {
const title = String(task?.task || ""); const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task); const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) { if (programWheelSelection === null) {
return title; return title;
} }
@@ -117,11 +127,13 @@ const formatTaskTitle = (task: any) => {
type="is-success" type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`" :data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)" @update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/> />
</b-field> </b-field>
</div> </div>
<div class="self-serve-task-image-overlay-text"> <div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p> <p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -144,21 +156,22 @@ const formatTaskTitle = (task: any) => {
</div> </div>
</div> </div>
</template> </template>
<div v-else class="self-serve-task-row"> <div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox"> <div v-if="props.showCheckboxes" class="column is-narrow">
<b-field> <b-field>
<b-checkbox <b-checkbox
class="mr-0 pr-0"
size="is-large" size="is-large"
:model-value="completedTasks[task.id] === true" :model-value="completedTasks[task.id] === true"
type="is-success" type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`" :data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)" @update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/> />
</b-field> </b-field>
</div> </div>
<div class="self-serve-task-content"> <div class="column">
<p><strong>{{ formatTaskTitle(task) }}</strong></p> <p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2"> <div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span> <span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div> </div>
@@ -226,7 +239,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.35rem; gap: 0.6rem;
pointer-events: auto; pointer-events: auto;
} }
@@ -234,30 +247,12 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0; margin-bottom: 0;
} }
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title { .self-serve-task-title {
margin: 0; margin: 0;
} }
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style> </style>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy"; import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue"; import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{ const props = defineProps<{
@@ -18,23 +18,6 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void; (e: "clear-dynamic-image"): void;
}>(); }>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const hasRenderableDynamicImageUrl = computed(
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
);
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
watch(
() => props.dynamicImageUrl,
(dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
},
{ immediate: true }
);
const emitToggleTask = (taskId: number, value: boolean) => { const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value); emit("toggle-task", taskId, value);
}; };
@@ -43,13 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
emit("download-attachment", taskId, attachmentId, attachment); emit("download-attachment", taskId, attachmentId, attachment);
}; };
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => { const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image"); emit("clear-dynamic-image");
}; };
</script> </script>
@@ -70,41 +47,11 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span> <span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div> </div>
<img <SelfServeDynamicImageFrame
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl" :src="dynamicImageUrl"
alt="" alt="Machine status"
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError" @error="onDynamicImageError"
/> />
<div
v-if="shouldRenderDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
data-testid="self-serve-dynamic-image-frame"
>
<div
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
data-testid="self-serve-dynamic-image-skeleton"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="showDynamicImageFrame"
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4"> <div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1> <h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1> <h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
@@ -118,49 +65,3 @@ const onDynamicImageError = () => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
.self-serve-dynamic-image-skeleton {
align-items: center;
aspect-ratio: 16 / 9;
background: #edf2f7;
color: #112f5f;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.self-serve-dynamic-image-preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy"; import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue"; import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -21,11 +21,8 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:customerNumber", value: string): void; (e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void; (e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void; (e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>(); }>();
const extractRegistrationValue = (value: unknown): string => { const extractRegistrationValue = (value: unknown): string => {
@@ -52,19 +49,6 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber)); const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => { watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue); typedRegistration.value = normalizeRegistration(newValue);
}); });
@@ -73,18 +57,6 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value); const normalized = normalizeRegistration(value);
typedRegistration.value = normalized; typedRegistration.value = normalized;
emit("update:registrationNumber", normalized); emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
}; };
</script> </script>
@@ -96,41 +68,38 @@ const emitVehicleTypeSelection = (selection: any) => {
:model-value="customerNumber || ''" :model-value="customerNumber || ''"
data-testid="self-serve-customer-number" data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')" :placeholder="$t('self_wash.enter_customer_number')"
@input="emitCustomerNumber($event)" @input="emit('update:customerNumber', String($event || ''))"
@update:modelValue="emitCustomerNumber($event)"
/> />
</b-field> </b-field>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field"> <b-field :label="$t('self_wash.registration_number')">
<div class="self-serve-registration-control"> <b-autocomplete
<b-autocomplete :model-value="typedRegistration"
:model-value="typedRegistration" data-testid="self-serve-registration"
data-testid="self-serve-registration" :data="registrationOptions"
:data="filteredRegistrationOptions" :placeholder="$t('self_wash.enter_registration_number')"
:placeholder="$t('self_wash.enter_registration_number')" :debounce="300"
:debounce="300" :min-length="1"
:min-length="1" :filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas" icon-pack="fas"
icon="car-side" icon="car-side"
clearable clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading" :loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)" :selectable-header="true"
@typing="emitRegistration($event)" @select="emitRegistration($event)"
@update:modelValue="emitRegistration($event)" @typing="emitRegistration($event)"
> @update:modelValue="emitRegistration($event)"
</b-autocomplete> @select-header="emitRegistration(typedRegistration)"
<p >
v-if="typedRegistration" <template #header>
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<template v-if="hasMatchingVehicle"> <template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }} {{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template> </template>
<template v-else> <template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }} {{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template> </template>
</p> </template>
</div> <template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</b-field> </b-field>
<b-message <b-message
v-if="props.vehicleStepError" v-if="props.vehicleStepError"
@@ -155,7 +124,7 @@ const emitVehicleTypeSelection = (selection: any) => {
<SelfServeVehicleTypeSelector <SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId" :selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds" :restrictToProductIds="availableProductIds"
@selected="emitVehicleTypeSelection" @selected="emit('select-vehicle-type', $event)"
/> />
</template> </template>
<template v-else-if="vehicleTypes.length === 0"> <template v-else-if="vehicleTypes.length === 0">
@@ -171,34 +140,3 @@ const emitVehicleTypeSelection = (selection: any) => {
</b-field> </b-field>
</div> </div>
</template> </template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -1,81 +0,0 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -1,260 +0,0 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
showProgressActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showProgressActions: true,
});
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [];
openPropertyExitGate: [];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
Array.isArray(props.guidedWashFlowSteps) &&
props.guidedWashFlowSteps.length > 0 &&
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate() {
emit("openPropertyAccessGate");
}
function handleOpenExitGate() {
emit("openPropertyExitGate");
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash && showProgressActions"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
v-if="showProgressActions"
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
v-show="showProgressActions"
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="handleOpenAccessGate"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="handleOpenExitGate"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -1,159 +0,0 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -129,19 +129,6 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0); return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
})); }));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
if (flags.length === 0) {
return "";
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(() => ( const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0) 2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
)); ));
@@ -175,17 +162,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-if="!isOrderItemAddon(item)"> <template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally --> <!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice"> <tr v-if="item.include_in_invoice">
<td> <td>{{ item.product.name }}</td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td> <td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td> <td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
@@ -193,17 +170,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr> </tr>
<!-- If the item is not included in the invoice, show it with a strikethrough --> <!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light"> <tr v-else class="has-background-warning-light">
<td> <td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td> <td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td> <td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
@@ -226,17 +193,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id"> <template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally --> <!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice"> <tr v-if="addon_item.include_in_invoice">
<td> <td>+ {{ addon_item.product.name }}</td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td> <td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td> <td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td> <td>{{ addon_item.quantity }}</td>
@@ -244,17 +201,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr> </tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough --> <!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning"> <tr v-else class="has-background-warning">
<td> <td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td> <td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td> <td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td> <td>{{ addon_item.quantity }}</td>
@@ -290,14 +237,6 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- If the item is a primary item, show it --> <!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)"> <template v-if="!isOrderItemAddon(item)">
<div> <div>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<strong>{{ item.product.name }}</strong> x {{ item.quantity }} <strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span> <span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div> </div>
@@ -309,14 +248,6 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- Show the add-on items --> <!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4"> <div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small> <small>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }} + {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span> <span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small> </small>
@@ -334,7 +265,5 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template> </template>
<style scoped> <style scoped>
.invoice-period-item-flag-indicator {
vertical-align: middle;
}
</style> </style>
@@ -251,8 +251,8 @@ const onResendInvite = async (subuser) => {
<td>{{ formatDateTime(subuser.updated_at) }}</td> <td>{{ formatDateTime(subuser.updated_at) }}</td>
<td> <td>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`"> <div class="buttons is-justify-content-flex-end action-buttons">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)"> <ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
<template #actions> <template #actions>
<ActionSettingsWheelItem <ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id" v-if="canEditPermissions() && subuser.grant_id"
@@ -255,16 +255,6 @@ const getBookingMatchesStateKey = (matches) => {
return matches.map((booking) => getBookingStateKey(booking)).join("||"); return matches.map((booking) => getBookingStateKey(booking)).join("||");
}; };
const hasBookingDetailsChanged = (currentBooking, nextBooking) => {
const currentKeys = Object.keys(currentBooking || {});
const nextKeys = Object.keys(nextBooking || {});
if (currentKeys.length !== nextKeys.length) {
return true;
}
return nextKeys.some((key) => currentBooking?.[key] !== nextBooking?.[key]);
};
const setVehicleObject = (emittedVehicleObject, options = {}) => { const setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = { const normalizedOptions = {
preserveManualReference: true, preserveManualReference: true,
@@ -360,23 +350,17 @@ const mergeBookingMatchDetails = (booking) => {
} }
let didMergeBooking = false; let didMergeBooking = false;
let didChangeBooking = false; bookingMatches.value = bookingMatches.value.map((entry) => {
const nextBookingMatches = bookingMatches.value.map((entry) => {
if (normalizeBookingId(entry?.id) !== normalizedBookingId) { if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
return entry; return entry;
} }
didMergeBooking = true; didMergeBooking = true;
const mergedBooking = { ...entry, ...booking }; return { ...entry, ...booking };
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
didChangeBooking = didChangeBooking || hasChangedEntry;
return hasChangedEntry ? mergedBooking : entry;
}); });
if (!didMergeBooking) { if (!didMergeBooking) {
bookingMatches.value = [...bookingMatches.value, booking]; bookingMatches.value = [...bookingMatches.value, booking];
} else if (didChangeBooking) {
bookingMatches.value = nextBookingMatches;
} }
}; };
@@ -1037,7 +1037,6 @@ const getCurrentIconColor = () => {
@blur="lostfocus" @blur="lostfocus"
@keydown="arrowKeyHandler" @keydown="arrowKeyHandler"
id="reg_1" id="reg_1"
tabindex="1"
autocomplete="off" autocomplete="off"
/> />
</template> </template>
@@ -1060,7 +1059,6 @@ const getCurrentIconColor = () => {
@blur="lostfocus" @blur="lostfocus"
@keydown="arrowKeyHandler" @keydown="arrowKeyHandler"
id="reg_1" id="reg_1"
tabindex="1"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@@ -394,16 +394,12 @@ onBeforeUnmount(() => {
v-model="typedReference" v-model="typedReference"
:data="groupedSuggestions" :data="groupedSuggestions"
field="reference" field="reference"
input-id="reference"
data-testid="pos-desktop-step-1-reference-input"
group-field="group" group-field="group"
group-options="items" group-options="items"
:keep-first="true" :keep-first="true"
open-on-focus open-on-focus
expanded expanded
:loading="isFetching" :loading="isFetching"
:tabindex="props.tabindex"
:aria-invalid="props.requiredWarning ? 'true' : undefined"
custom-class="has-sharp-edges" custom-class="has-sharp-edges"
icon-pack="fas" icon-pack="fas"
max-height="320" max-height="320"
@@ -1,217 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const SHIFT_PRESS_WINDOW_MS = 1200;
const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit || releaseUpdateState.currentCommit));
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
}
};
const handleKeydown = (event) => {
if (event.key !== "Shift" || event.repeat) {
return;
}
const now = Date.now();
shiftPresses.value = [...shiftPresses.value.filter((pressedAt) => now - pressedAt <= SHIFT_PRESS_WINDOW_MS), now];
if (shiftPresses.value.length >= 3) {
shiftPresses.value = [];
isOpen.value = true;
}
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) {
return;
}
isBusy.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isBusy.value = false;
console.error("Failed to force frontend update and clear local data:", error);
}
};
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener(CLOSE_EVENT, closeMenu);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(CLOSE_EVENT, closeMenu);
});
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="frontend-maintenance-menu" data-testid="frontend-maintenance-menu">
<button
type="button"
class="frontend-maintenance-menu__backdrop"
:aria-label="t('common.close')"
@click="closeMenu"
></button>
<section class="frontend-maintenance-menu__panel" role="dialog" aria-modal="true" :aria-label="t('maintenance_menu.title')">
<header class="frontend-maintenance-menu__header">
<div>
<p>{{ t("maintenance_menu.title") }}</p>
<span>{{ t("maintenance_menu.version", { current: currentVersion, latest: latestVersion }) }}</span>
</div>
<button type="button" class="frontend-maintenance-menu__close" :aria-label="t('common.close')" @click="closeMenu">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<button
type="button"
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="forceUpdateAndClearLocal"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
<strong>{{ t("maintenance_menu.force_update_clear") }}</strong>
<small>{{ t("maintenance_menu.force_update_clear_hint") }}</small>
</span>
</button>
</section>
</div>
</Teleport>
</template>
<style scoped>
.frontend-maintenance-menu {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: end center;
padding: 16px;
pointer-events: none;
}
.frontend-maintenance-menu__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(12, 22, 34, 0.32);
pointer-events: auto;
}
.frontend-maintenance-menu__panel {
position: relative;
width: min(420px, calc(100vw - 32px));
border: 1px solid #d6dde6;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
color: #111827;
pointer-events: auto;
}
.frontend-maintenance-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 10px;
border-bottom: 1px solid #edf1f5;
}
.frontend-maintenance-menu__header p {
margin: 0;
color: #172033;
font-size: 0.96rem;
font-weight: 800;
}
.frontend-maintenance-menu__header span {
display: block;
margin-top: 3px;
color: #667085;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.72rem;
font-weight: 700;
}
.frontend-maintenance-menu__close {
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid #d9e0e8;
border-radius: 6px;
background: #ffffff;
color: #334155;
cursor: pointer;
}
.frontend-maintenance-menu__danger {
width: calc(100% - 28px);
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 14px;
padding: 10px 12px;
border: 1px solid #f2b8b5;
border-radius: 6px;
background: #fff7f6;
color: #981b1b;
cursor: pointer;
text-align: left;
}
.frontend-maintenance-menu__danger:disabled {
cursor: wait;
opacity: 0.7;
}
.frontend-maintenance-menu__danger i {
width: 20px;
flex: 0 0 auto;
text-align: center;
}
.frontend-maintenance-menu__danger strong,
.frontend-maintenance-menu__danger small {
display: block;
}
.frontend-maintenance-menu__danger strong {
font-size: 0.86rem;
font-weight: 800;
}
.frontend-maintenance-menu__danger small {
margin-top: 2px;
color: #9f1c1c;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.25;
}
@media (min-width: 720px) {
.frontend-maintenance-menu {
place-items: end end;
padding: 22px;
}
}
</style>
@@ -44,7 +44,6 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
]); ]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700; const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close"; const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault); const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false); const isShortcutActivated = ref(false);
@@ -367,9 +366,6 @@ const handleWindowKeydown = (event) => {
} }
if (shiftKeyPressCount.value >= 3) { if (shiftKeyPressCount.value >= 3) {
event.preventDefault?.();
event.stopImmediatePropagation?.();
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
closeSystemSearchForSuperuser(); closeSystemSearchForSuperuser();
isShortcutActivated.value = true; isShortcutActivated.value = true;
isExpanded.value = true; isExpanded.value = true;
@@ -1500,3 +1496,4 @@ onBeforeUnmount(() => {
opacity: 0; opacity: 0;
} }
</style> </style>
+2 -2
View File
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue"; import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js"; import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
// Get the department ID from the URL // Get the department ID from the URL
const route = useRoute(); const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null; const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -26,7 +26,7 @@ const loadDepartments = () => {
value: department.id, value: department.id,
icon: "fas fa-building", icon: "fas fa-building",
children: [], children: [],
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment), hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
}); });
}); });
menu_items.value[1].options = options; menu_items.value[1].options = options;
@@ -13,9 +13,7 @@ import {
} from "@/composables/useDraftTransactionCustomer.js"; } from "@/composables/useDraftTransactionCustomer.js";
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js"; import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js"; import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js"; import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
import { isAccessibleVisibleNamedDepartment } from "@/services/departmentVisibility.js";
const t = (key: string) => i18n.global.t(key); const t = (key: string) => i18n.global.t(key);
@@ -72,8 +70,6 @@ let queuedBookingLoadingIndicator = false;
let queuedDraftLoadingIndicator = false; let queuedDraftLoadingIndicator = false;
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10); const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin(); const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
const canUseAdminBookingNavigationCounts = () =>
canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const getDepartmentById = (id: number) => { const getDepartmentById = (id: number) => {
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null; return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
}; };
@@ -152,19 +148,6 @@ const getDepartmentDraftBadge = () => {
}; };
const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => { const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseAdminBookingNavigationCounts()) {
department_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
department_booking_counts_loading.value = false;
bookingCountFetchInFlight = false;
bookingCountRefreshQueued = false;
queuedBookingLoadingIndicator = false;
return;
}
if (!hasValidDepartmentId.value) { if (!hasValidDepartmentId.value) {
department_booking_counts.value = { department_booking_counts.value = {
past: 0, past: 0,
@@ -393,26 +376,17 @@ const items = computed<NavigationItemProps[]>(() => [
hidden: isDepartmentSet.value, hidden: isDepartmentSet.value,
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS], classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
permissions: ["admin", "list_departments"], permissions: ["admin", "list_departments"],
children: SessionUser.functions children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => {
.getAccessibleDepartments() const department = getDepartmentById(departmentId);
.map((departmentId: any) => { return {
const label = getDepartmentName(departmentId); label: getDepartmentName(departmentId),
const department = getDepartmentById(departmentId); to: `/admin/${departmentId}`,
type: "department",
if (!isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment)) { permissions: ["admin", "view_department"],
return null; order_priority: department?.order_priority ?? department?.priority_order ?? null,
} priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
return { }),
label,
to: `/admin/${department.id}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
})
.filter(Boolean) as NavigationItemProps[],
}, },
// Kassesystem // Kassesystem
{ {
@@ -9,7 +9,6 @@ import {
} from "@/composables/useDraftTransactionCustomer.js"; } from "@/composables/useDraftTransactionCustomer.js";
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js"; import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js"; import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js"; import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -38,29 +37,23 @@ let queuedSuperUserBookingLoadingIndicator = false;
let queuedSuperUserDraftLoadingIndicator = false; let queuedSuperUserDraftLoadingIndicator = false;
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser(); const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
const canUseSuperUserBookingNavigationCounts = () =>
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const resetSuperUserBookingCounts = () => { const resetSuperUserNavigationCounts = () => {
bookingCountRequestId += 1; bookingCountRequestId += 1;
draftCountRequestId += 1;
superuser_booking_counts.value = { superuser_booking_counts.value = {
past: 0, past: 0,
current: 0, current: 0,
future: 0, future: 0,
}; };
superuser_booking_count_loading.value = false; superuser_booking_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
};
const resetSuperUserNavigationCounts = () => {
resetSuperUserBookingCounts();
draftCountRequestId += 1;
superuser_draft_count.value = 0; superuser_draft_count.value = 0;
superuser_draft_count_loading.value = false; superuser_draft_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserDraftCountFetchInFlight = false; superUserDraftCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
superUserDraftCountRefreshQueued = false; superUserDraftCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
queuedSuperUserDraftLoadingIndicator = false; queuedSuperUserDraftLoadingIndicator = false;
}; };
@@ -132,8 +125,8 @@ const getSuperUserDraftsBadge = () => {
}; };
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => { const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserBookingNavigationCounts()) { if (!canUseSuperUserNavigationCounts()) {
resetSuperUserBookingCounts(); resetSuperUserNavigationCounts();
return; return;
} }
@@ -1,8 +1,4 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const normalizePositiveInteger = (value) => { const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10); const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -28,7 +24,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId); const normalizedDepartmentId = normalizePositiveInteger(departmentId);
if (!normalizedDepartmentId) { if (!normalizedDepartmentId) {
return emptyBookingCounts(); return {
past: 0,
current: 0,
future: 0,
};
} }
try { try {
@@ -38,11 +38,12 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
return normalizeBookingCounts(response); return normalizeBookingCounts(response);
} catch (error) { } catch (error) {
if (!isExpectedBookingCountRequestFailure(error)) { console.error("Error fetching department order-booking counts:", error);
console.error("Error fetching department order-booking counts:", error); return {
} past: 0,
current: 0,
return emptyBookingCounts(); future: 0,
};
} }
}; };
@@ -1,24 +0,0 @@
export const BOOKING_COUNT_PERMISSIONS = ["list_bookings", "list_own_bookings"];
export const emptyBookingCounts = () => ({
past: 0,
current: 0,
future: 0,
});
export const hasExplicitBookingCountPermission = (sessionUser) => {
const permissions = sessionUser?.permissions?.value;
if (!Array.isArray(permissions)) {
return false;
}
return BOOKING_COUNT_PERMISSIONS.some((permission) => permissions.includes(permission));
};
export const getRequestErrorStatus = (error) =>
Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10);
export const isExpectedBookingCountRequestFailure = (error) => {
return [401, 403, 404].includes(getRequestErrorStatus(error));
};
@@ -1,8 +1,4 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const toNonNegativeInteger = (value) => { const toNonNegativeInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10); const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -24,11 +20,12 @@ export const fetchSuperUserBookingCounts = async () => {
const response = await authenticatedRequest("/order-bookings/counts", "GET", {}); const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
return normalizeBookingCounts(response); return normalizeBookingCounts(response);
} catch (error) { } catch (error) {
if (!isExpectedBookingCountRequestFailure(error)) { console.error("Error fetching superuser booking counts:", error);
console.error("Error fetching superuser booking counts:", error); return {
} past: 0,
current: 0,
return emptyBookingCounts(); future: 0,
};
} }
}; };
@@ -1,8 +1,9 @@
<script setup> <script setup>
import { useRouter } from "vue-router";
import { useRouter } from 'vue-router';
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { pingApiServer } from "@/services/apiHealth.js"; import { pingApiServer } from "@/services/apiHealth.js";
const props = defineProps({ const props = defineProps({
@@ -41,12 +42,12 @@ const handleSlowSessionBootstrap = async () => {
} }
const result = await Swal.fire({ const result = await Swal.fire({
title: "Error", title: 'Error',
text: "The user session could not be initiated.", text: 'The user session could not be initiated.',
icon: "error", icon: 'error',
confirmButtonText: "Clear session, and try again", confirmButtonText: 'Clear session, and try again',
showCancelButton: true, showCancelButton: true,
cancelButtonText: "Retry", cancelButtonText: 'Retry',
}); });
if (result.isConfirmed) { if (result.isConfirmed) {
@@ -59,16 +60,10 @@ const handleSlowSessionBootstrap = async () => {
} }
}; };
watch(isSessionInitiated, (initiated) => {
if (initiated) {
clearSessionTimeout();
}
});
onMounted(() => { onMounted(() => {
sessionTimeout = setTimeout(() => { sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap(); void handleSlowSessionBootstrap();
}, 15000); }, 5000);
}); });
onBeforeUnmount(clearSessionTimeout); onBeforeUnmount(clearSessionTimeout);
@@ -94,18 +89,9 @@ onBeforeUnmount(clearSessionTimeout);
</div> </div>
</div> </div>
<!-- Simple checks --> <!-- Simple checks -->
<p v-if="router.currentRoute.value.path === '/user'"> <p v-if="router.currentRoute.value.path === '/user'">You are on the /user page. <br>You do {{ SessionUser.hasPermission('user') ? '' : 'not' }} have permission ( user ) to view this page.</p>
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission ( <p v-if="router.currentRoute.value.path === '/admin'">You are on the /admin page. <br>You do {{ SessionUser.hasPermission('admin') ? '' : 'not' }} have permission ( admin ) to view this page.</p>
user ) to view this page. <p v-if="router.currentRoute.value.path === '/superuser'">You are on the /superuser <br>page. You do {{ SessionUser.hasPermission('superuser') ? '' : 'not' }} have permission ( superuser ) to view this page.</p>
</p>
<p v-if="router.currentRoute.value.path === '/admin'">
You are on the /admin page. <br />You do {{ SessionUser.hasPermission("admin") ? "" : "not" }} have permission (
admin ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/superuser'">
You are on the /superuser <br />page. You do {{ SessionUser.hasPermission("superuser") ? "" : "not" }} have
permission ( superuser ) to view this page.
</p>
<div v-if="showDebugInfo"> <div v-if="showDebugInfo">
<div class="message mb-6"> <div class="message mb-6">
<div class="message-header"> <div class="message-header">
@@ -122,9 +108,7 @@ onBeforeUnmount(clearSessionTimeout);
<!-- Button to go back to the previous page --> <!-- Button to go back to the previous page -->
<button class="button is-dark" @click="router.go(-1)">Go back</button> <button class="button is-dark" @click="router.go(-1)">Go back</button>
<!-- Button to see the debug information --> <!-- Button to see the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo"> <button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">Show debug information</button>
Show debug information
</button>
<!-- Button to hide the debug information --> <!-- Button to hide the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button> <button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
</div> </div>
@@ -132,4 +116,6 @@ onBeforeUnmount(clearSessionTimeout);
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
</style>
@@ -1,21 +1,42 @@
<script setup> <script setup>
import { ref } from "vue"; import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue"; import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue"; import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.js"; import {
clearSelectedReleaseChannel,
releaseChannelSelectorVisible,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
import {
isReleaseSourceOverrideAvailable,
RELEASE_SOURCE_MODES,
setReleaseSourceOverride,
} from "@/services/releaseBootstrap.js";
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js"; import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js"; import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const switchingSlug = ref(""); const switchingSlug = ref("");
const switchError = ref(""); const switchError = ref("");
const { t, te } = useI18n({ useScope: "global" }); const { t, te } = useI18n({ useScope: "global" });
const reloadWindow = inject("releaseSourceReload", () => {
if (typeof window !== "undefined") {
window.location.reload();
}
});
const tr = (key, fallback) => { const tr = (key, fallback) => {
const path = `configuration.release_manager.channel_selector.${key}`; const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path) : fallback; return te(path) ? t(path) : fallback;
}; };
const showUseLocalFrontend = computed(
() =>
releaseChannelSelectorVisible.value &&
isReleaseSourceOverrideAvailable() &&
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
);
const switchChannel = async (option) => { const switchChannel = async (option) => {
if (switchingSlug.value) { if (switchingSlug.value) {
return; return;
@@ -27,14 +48,21 @@ const switchChannel = async (option) => {
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime); await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true }); void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
} catch (error) { } catch (error) {
switchError.value = tr( switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
"switch_error",
"Release channel could not be switched. The previous channel is still active."
);
} finally { } finally {
switchingSlug.value = ""; switchingSlug.value = "";
} }
}; };
const useLocalFrontend = () => {
if (switchingSlug.value) {
return;
}
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
clearSelectedReleaseChannel();
reloadWindow();
};
</script> </script>
<template> <template>
@@ -46,6 +74,17 @@ const switchChannel = async (option) => {
:disabled="Boolean(switchingSlug)" :disabled="Boolean(switchingSlug)"
@select="switchChannel" @select="switchChannel"
/> />
<button
v-if="showUseLocalFrontend"
type="button"
class="release-channel-sidebar-selector__local-button"
data-testid="release-channel-use-local-frontend"
:disabled="Boolean(switchingSlug)"
@click="useLocalFrontend"
>
<i class="fas fa-code" aria-hidden="true"></i>
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
</button>
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert"> <p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
{{ switchError }} {{ switchError }}
</p> </p>
@@ -54,6 +93,37 @@ const switchChannel = async (option) => {
</template> </template>
<style scoped> <style scoped>
.release-channel-sidebar-selector__local-button {
width: calc(100% - 32px);
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #b8c7d9;
border-radius: 5px;
background: #f8fbff;
color: #153554;
cursor: pointer;
font-size: 0.82rem;
font-weight: 800;
line-height: 1.2;
text-align: center;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
border-color: #6f8fb4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.release-channel-sidebar-selector__local-button:disabled {
cursor: default;
opacity: 0.7;
}
.release-channel-sidebar-selector__error { .release-channel-sidebar-selector__error {
margin: 0 16px 14px; margin: 0 16px 14px;
color: #b42318; color: #b42318;
+1 -14
View File
@@ -295,23 +295,10 @@ const branchWarning = computed(() =>
min-width: 12rem; min-width: 12rem;
} }
.release-context-bar__status {
align-items: flex-start;
flex-wrap: wrap;
}
.release-context-bar__status .tag {
flex: 0 0 auto;
}
.release-context-bar__warning { .release-context-bar__warning {
color: #9f1f17; color: #9f1f17;
flex: 1 1 12rem;
font-size: 0.82rem; font-size: 0.82rem;
line-height: 1.25; overflow-wrap: anywhere;
min-width: min(12rem, 100%);
overflow-wrap: break-word;
word-break: normal;
} }
.release-context-bar__endpoints { .release-context-bar__endpoints {
@@ -13,59 +13,7 @@ const getSelectedCustomerNumber = () => {
return stored ? parseInt(stored) : null; return stored ? parseInt(stored) : null;
}; };
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash'; export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
const parseDateTimeMs = (value) => {
if (!value) {
return null;
}
const timestamp = Date.parse(String(value));
return Number.isFinite(timestamp) ? timestamp : null;
};
const hasActiveWashStartEvidence = (details) => {
const session = details?.session || {};
const status = normalizeStatus(session?.status ?? details?.status);
return (
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
session?.machine_relay_enabled === true ||
session?.machine_start_triggered === true ||
details?.machine_relay_enabled === true ||
details?.machine_start_triggered === true ||
parseDateTimeMs(session?.wash_started_at) !== null ||
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
);
};
const normalizeActiveWashResponse = (url, method, response) => {
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
return response;
}
const details = response?.data?.data;
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
return response;
}
return {
...response,
data: {
...response.data,
data: {
...details,
in_progress: false,
},
},
};
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) { if (!token) {
//throw new Error('No token was found, unable to make authenticated request'); //throw new Error('No token was found, unable to make authenticated request');
@@ -94,7 +42,6 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
method, method,
url: requestUrl, url: requestUrl,
...(method === 'GET' ? { params: data } : { data }), ...(method === 'GET' ? { params: data } : { data }),
...(options?.signal ? { signal: options.signal } : {}),
__skipRequestQueue: true, __skipRequestQueue: true,
headers headers
}), }),
@@ -116,7 +63,6 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
throw error; throw error;
}) })
.then((response) => { .then((response) => {
response = normalizeActiveWashResponse(url, method, response);
if (thenCallable) { if (thenCallable) {
// Call the then callable // Call the then callable
thenCallable(response); thenCallable(response);
@@ -184,7 +184,6 @@ const resetSessionState = () => {
SessionUser.user.notifications.wash_certificate_email.value = null; SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null; SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null; SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = null;
SessionUser.user.created_at.value = null; SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null; SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null; SessionUser.user.cached_at.value = null;
@@ -529,8 +528,6 @@ export const getSessionData = async () => {
SessionUser.user.notifications.email_notifications_enabled.value = SessionUser.user.notifications.email_notifications_enabled.value =
session.notifications.email_notifications_enabled; session.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled; SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value =
session.notifications.superuser_new_customer_email_notifications_enabled;
SessionUser.user.created_at.value = session.created_at; SessionUser.user.created_at.value = session.created_at;
SessionUser.user.updated_at.value = session.updated_at; SessionUser.user.updated_at.value = session.updated_at;
SessionUser.user.display_name.value = session.display_name; SessionUser.user.display_name.value = session.display_name;
@@ -626,7 +623,6 @@ export const SessionUser = {
wash_certificate_email: ref(null), wash_certificate_email: ref(null),
email_notifications_enabled: ref(null), email_notifications_enabled: ref(null),
sms_notifications_enabled: ref(null), sms_notifications_enabled: ref(null),
superuser_new_customer_email_notifications_enabled: ref(null),
setEmailNotificationsEnabled: (enabled) => { setEmailNotificationsEnabled: (enabled) => {
return SessionUser.request("/account/notifications", "PUT", { return SessionUser.request("/account/notifications", "PUT", {
email_notifications_enabled: enabled, email_notifications_enabled: enabled,
@@ -651,18 +647,6 @@ export const SessionUser = {
console.error(error); console.error(error);
}); });
}, },
setSuperuserNewCustomerEmailNotificationsEnabled: (enabled) => {
return SessionUser.request("/account/notifications", "PUT", {
superuser_new_customer_email_notifications_enabled: enabled,
})
.then(() => {
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = enabled;
})
.catch((error) => {
parseError(error, "user_notifications");
console.error(error);
});
},
setWashCertificateEmail: (email) => { setWashCertificateEmail: (email) => {
return SessionUser.request("/account/notifications", "PUT", { return SessionUser.request("/account/notifications", "PUT", {
wash_certificate_email: email, wash_certificate_email: email,
@@ -456,29 +456,6 @@ export const CollectedOrderInvoices = {
throw error; throw error;
}); });
}, },
move_to_customer: async (id, customerNumber) => {
const invoiceCollectionId = parseInt(id);
const targetCustomerNumber = parseInt(customerNumber);
if (!Number.isInteger(invoiceCollectionId) || invoiceCollectionId <= 0) {
throw new Error('Invalid invoice collection id');
}
if (!Number.isInteger(targetCustomerNumber) || targetCustomerNumber <= 0) {
throw new Error('Invalid customer number');
}
return authenticatedRequest('/collected-invoices/move-to-customer', 'POST', {
id: invoiceCollectionId,
customer_number: targetCustomerNumber,
}).then((response) => {
console.log(response);
return response;
}).catch((error) => {
console.log(error);
throw error;
});
},
showCreateNewCustom: showCreateCustomInvoiceCollectionForm, showCreateNewCustom: showCreateCustomInvoiceCollectionForm,
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal, showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
add_vehicle_subscriptions: async (id) => { add_vehicle_subscriptions: async (id) => {
@@ -843,3 +820,4 @@ export const CollectedOrderInvoices = {
} }
}; };
</script> </script>
@@ -173,7 +173,7 @@ export const SelfServeVehicleConditions = {
single: async (id) => { single: async (id) => {
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id); return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
}, },
previewAllowed: async (laneId, reg, vehicleTypeId, options = {}) => { previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
const params = { const params = {
lane_id: parseInt(laneId), lane_id: parseInt(laneId),
reg: reg, reg: reg,
@@ -184,11 +184,11 @@ export const SelfServeVehicleConditions = {
params.vehicle_type = normalizedVehicleTypeId; params.vehicle_type = normalizedVehicleTypeId;
} }
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params, null, null, options) return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params)
.then((response) => response.data.data || response.data); .then((response) => response.data.data || response.data);
}, },
washSummary: async (params, options) => { washSummary: async (params = {}) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params || {}, null, null, options || {}) return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
.then((response) => response.data.data || response.data); .then((response) => response.data.data || response.data);
} }
}, },
@@ -61,14 +61,6 @@ export const Config = {
}); });
}, },
keys: { keys: {
dynamic_image_size: {
get: async () => {
return Config.get("dynamic_image_size");
},
set: async (value) => {
return Config.set("dynamic_image_size", value);
},
},
minute_product: { minute_product: {
get: async () => { get: async () => {
return Config.get("minute_product"); return Config.get("minute_product");
@@ -1,31 +0,0 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* The Slack -> Config object
*/
export const Config = {
get: async (variable) => {
return authenticatedRequest("/slack/config?variable=" + variable, "GET");
},
get_all: async () => {
return authenticatedRequest("/slack/config", "GET");
},
set: async (variable, value) => {
return authenticatedRequest("/slack/config", "POST", {
variable: variable,
value: value,
});
},
keys: {
customer_registration_webhook_url: {
get: async () => {
return Config.get("customer_registration_webhook_url");
},
set: async (value) => {
return Config.set("customer_registration_webhook_url", value);
},
},
},
};
</script>
@@ -1,21 +0,0 @@
<script>
import { Config } from "@/components/session/token/superUser/modules/slack/Config.vue";
/**
* The Slack object
*/
export const Slack = {
meta: {
title: "Slack",
icon: "fab fa-slack",
description: "Slack notifications",
endpoint: "/slack/config",
config_endpoint: "/configuration/slack",
labels: {
single: "slack",
multiple: "slack",
},
},
config: Config,
};
</script>
@@ -28,7 +28,6 @@ import { EdgeGateway } from "@/components/session/token/superUser/modules/edgega
import { Failover } from "@/components/session/token/superUser/modules/failover/Failover.vue"; import { Failover } from "@/components/session/token/superUser/modules/failover/Failover.vue";
import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue"; import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue";
import { ReleaseManager } from "@/components/session/token/superUser/modules/releasemanager/ReleaseManager.vue"; import { ReleaseManager } from "@/components/session/token/superUser/modules/releasemanager/ReleaseManager.vue";
import { Slack } from "@/components/session/token/superUser/modules/slack/Slack.vue";
let qrCodeModulePromise; let qrCodeModulePromise;
@@ -73,7 +72,6 @@ export const SuperUserObject = {
get failover() { return Failover; }, get failover() { return Failover; },
get coolify() { return Coolify; }, get coolify() { return Coolify; },
get releasemanager() { return ReleaseManager; }, get releasemanager() { return ReleaseManager; },
get slack() { return Slack; },
}, },
/** Intimidate a user */ /** Intimidate a user */
intimidate: { intimidate: {
+11 -21
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
import axios from "axios"; import axios from "axios";
import { API_URL } from "@/config.js"; import { API_URL } from "@/config.js";
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue"; import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
import { useRouter } from "vue-router";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue"; import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue"; import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue"; import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
@@ -130,12 +131,12 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
// If the current step is 2, set the product category to addons // If the current step is 2, set the product category to addons
setProductsCategory(4); setProductsCategory(4);
// Set the step to 3 in the query parameters // Set the step to 3 in the query parameters
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=3`); window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
} }
if (step.value === 3) { if (step.value === 3) {
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) { if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
step.value = 4; step.value = 4;
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=4`); window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
return; return;
} }
@@ -230,7 +231,11 @@ const toPositiveInteger = (value) => {
}; };
const getRouteDepartmentId = () => { const getRouteDepartmentId = () => {
return getWindowPathDepartmentId(); try {
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
} catch (error) {
return null;
}
}; };
const getSessionUrlDepartmentId = () => { const getSessionUrlDepartmentId = () => {
@@ -752,7 +757,7 @@ export const reset_all_values = () => {
// Reset all values to their initial state // Reset all values to their initial state
clearActivePosOrderContext({ clearStep: true }); clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters // Clear the query parameters
pushPosRouteState("step=1"); window.history.pushState({}, "", `?step=1`);
}; };
/** Whenever the reg_1, reg_2 or reg_3 changes, remove any spaces and make the string uppercase */ /** Whenever the reg_1, reg_2 or reg_3 changes, remove any spaces and make the string uppercase */
@@ -763,21 +768,6 @@ const normalizeRegistrationValue = (value) =>
export const isBlankPosMetadataValue = (value) => String(value ?? "").trim().length === 0; export const isBlankPosMetadataValue = (value) => String(value ?? "").trim().length === 0;
const getPosRoutePath = () => {
const normalizedDepartmentId = Number.parseInt(String(department_id.value || ""), 10);
if (Number.isInteger(normalizedDepartmentId) && normalizedDepartmentId > 0) {
return `/admin/${normalizedDepartmentId}/modules/pos`;
}
return window.location.pathname && window.location.pathname !== "/" ? window.location.pathname : "/admin/modules/pos";
};
export const pushPosRouteState = (queryString) => {
const normalizedQueryString = String(queryString || "").replace(/^\?/, "");
window.history.pushState({}, "", `${getPosRoutePath()}?${normalizedQueryString}`);
};
watch([reg_1, reg_2, reg_3], () => { watch([reg_1, reg_2, reg_3], () => {
reg_1.value = normalizeRegistrationValue(reg_1.value); reg_1.value = normalizeRegistrationValue(reg_1.value);
reg_2.value = normalizeRegistrationValue(reg_2.value); reg_2.value = normalizeRegistrationValue(reg_2.value);
@@ -924,7 +914,7 @@ export const createOrder = async (options = { isMobile: false }) => {
// Set the order id in the local storage (To be make F5 safe) // Set the order id in the local storage (To be make F5 safe)
localStorage.setItem("pos_order_id", order_id.value); localStorage.setItem("pos_order_id", order_id.value);
// Set the query parameters // Set the query parameters
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`); window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
return true; return true;
}) })
.catch((error) => { .catch((error) => {
@@ -1641,7 +1631,7 @@ export const showDeleteEverythingDialog = () => {
/** Clear cache */ /** Clear cache */
export const clearCache = () => { export const clearCache = () => {
// Reset the query parameters // Reset the query parameters
pushPosRouteState("step=1"); window.history.pushState({}, "", `?step=1`);
// Clear the customer data // Clear the customer data
clearCustomerSelection(); clearCustomerSelection();
// Clear the order data // Clear the order data
@@ -13,7 +13,8 @@ import {
getDepartmentSelectionVisibility, getDepartmentSelectionVisibility,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue"; } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { import {
isAccessibleVisibleNamedDepartment, isAccessibleVisibleDepartment,
isDepartmentLabelValid,
sortByDepartmentPriorityOrder, sortByDepartmentPriorityOrder,
} from "@/services/departmentVisibility.js"; } from "@/services/departmentVisibility.js";
@@ -34,7 +35,7 @@ const options = computed(() => {
// Filter out departments the user does not have access to // Filter out departments the user does not have access to
return departments.value return departments.value
.filter((department: department) => { .filter((department: department) => {
return isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment); return isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment);
}) })
.map((department: department) => { .map((department: department) => {
return { return {
@@ -55,7 +56,7 @@ const mobileOptions = computed(() => {
}); });
const desktopOptions = computed(() => { const desktopOptions = computed(() => {
return sortByDepartmentPriorityOrder(options.value); return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
}); });
const selectDepartment = (departmentId: number | string) => { const selectDepartment = (departmentId: number | string) => {
@@ -1,26 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'; import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']); const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null); const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null); const canvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null); const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false); const isCameraActive = ref(false);
const cameraErrorKey = ref('pos.camera_permission_denied');
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue'; import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
const getCameraErrorKey = (err: unknown) => {
const errorName = err instanceof DOMException ? err.name : '';
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
return 'pos.no_camera_found';
}
return 'pos.camera_permission_denied';
};
function startCamera() { function startCamera() {
const constraints = { const constraints = {
video: { video: {
@@ -58,7 +45,6 @@ function startCamera() {
}) })
.catch((err) => { .catch((err) => {
isCameraActive.value = false; isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error('Camera access error:', err); console.error('Camera access error:', err);
}); });
} }
@@ -185,7 +171,8 @@ watch(() => isCameraActive.value, (newVal) => {
></canvas> ></canvas>
<div v-if="!isCameraActive" class="camera-inactive"> <div v-if="!isCameraActive" class="camera-inactive">
<p>{{ t(cameraErrorKey) }}</p> <p>Camera is not active.</p>
<p>Please enable camera access.</p>
</div> </div>
</div> </div>
</template> </template>
+105 -242
View File
@@ -1,4 +1,5 @@
import { computed, getCurrentInstance, onUnmounted, ref } from "vue"; import { computed, ref } from "vue";
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js"; import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
@@ -271,56 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged; return merged;
}; };
const taskStableKey = (task) => let selfServeLogicStoreInstance = 0;
String(task?.task ?? "") let fallbackSelfServePinia = null;
.trim()
.toLowerCase();
const mergeTaskSets = (existingTasks, incomingTasks) => { const ensureSelfServePinia = () => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) { if (typeof document === "undefined") {
return Array.isArray(incomingTasks) ? [...incomingTasks] : []; fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
} }
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) { const activePinia = getActivePinia();
return [...existingTasks]; if (activePinia) {
return activePinia;
} }
fallbackSelfServePinia ||= createPinia();
const usedExistingIndexes = new Set(); setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
return incomingTasks.map((incomingTask) => {
const incomingId = extractTaskId(incomingTask);
let existingIndex = -1;
if (incomingId) {
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
} else {
const incomingKey = taskStableKey(incomingTask);
existingIndex = existingTasks.findIndex(
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
);
}
if (existingIndex === -1) {
return incomingTask;
}
usedExistingIndexes.add(existingIndex);
const existingTask = existingTasks[existingIndex];
return {
...existingTask,
...incomingTask,
id: incomingTask.id || existingTask.id,
task_id: incomingTask.task_id || existingTask.task_id,
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
? incomingTask.attachments
: existingTask.attachments,
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
};
});
}; };
export function useSelfServeLogic() { const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
const loading = ref(false); const loading = ref(false);
const requestError = ref(null); const requestError = ref(null);
const preview = ref(null); const preview = ref(null);
@@ -345,94 +316,19 @@ export function useSelfServeLogic() {
const lastResolvedVehicleTypeId = ref(null); const lastResolvedVehicleTypeId = ref(null);
const summaryVisibleQuestionIds = ref([]); const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({}); const summaryQuestionOrder = ref({});
const activeFetchAbortController = ref(null); const latestFetchRequestId = ref(0);
const latestMutationRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => { const beginFetchRequest = () => {
const normalizedDepartmentId = parseInt(departmentId); latestFetchRequestId.value += 1;
const normalizedLaneId = parseInt(laneId); return latestFetchRequestId.value;
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
}; };
const isCanceledRequest = (error) => ( const isFetchRequestActive = (requestId) => (
error?.code === "ERR_CANCELED" requestId === null
|| error?.name === "CanceledError" || requestId === undefined
|| error?.name === "AbortError" || requestId === latestFetchRequestId.value
); );
const abortActiveFetchRequest = () => {
// Abort the previous self-serve read before a newer one starts; only the live signal may write state.
activeFetchAbortController.value?.abort();
activeFetchAbortController.value = null;
};
const beginFetchRequest = (externalSignal = null) => {
abortActiveFetchRequest();
if (typeof AbortController !== "function") {
return null;
}
const abortController = new AbortController();
if (externalSignal) {
if (externalSignal.aborted) {
abortController.abort();
} else {
externalSignal.addEventListener("abort", () => abortController.abort(), { once: true });
}
}
activeFetchAbortController.value = abortController;
return abortController;
};
const isFetchSignalActive = (signal = null) => (
!signal || (!signal.aborted && activeFetchAbortController.value?.signal === signal)
);
const requestPreviewAllowed = (laneId, reg, vehicleTypeId = null, signal = null) => {
const previewAllowed = SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed;
if (!signal || previewAllowed.length < 3) {
return vehicleTypeId
? previewAllowed(laneId, reg, vehicleTypeId)
: previewAllowed(laneId, reg);
}
return previewAllowed(laneId, reg, vehicleTypeId, { signal });
};
const requestWashSummary = (params = {}, signal = null) => {
const washSummary = SessionUser.objects.self_serve_vehicle_conditions.get.washSummary;
if (!signal || washSummary.length < 2) {
return washSummary(params);
}
return washSummary(params, { signal });
};
const beginMutationRequest = () => {
latestMutationRequestId.value += 1;
return latestMutationRequestId.value;
};
const isMutationRequestActive = (requestId) => requestId === latestMutationRequestId.value;
const setSummaryVisibleQuestions = (questionList = []) => { const setSummaryVisibleQuestions = (questionList = []) => {
const normalizedIds = (Array.isArray(questionList) ? questionList : []) const normalizedIds = (Array.isArray(questionList) ? questionList : [])
.map((question) => parseInt(question?.id ?? 0)) .map((question) => parseInt(question?.id ?? 0))
@@ -539,8 +435,8 @@ export function useSelfServeLogic() {
}; };
const applyPreviewData = async (previewData, options = {}) => { const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false, signal = null } = options; const { mergeQuestions = false, requestId = null } = options;
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return false; return false;
} }
@@ -549,10 +445,6 @@ export function useSelfServeLogic() {
machineType.value = previewData?.machine_type || machineType.value; machineType.value = previewData?.machine_type || machineType.value;
vehicle.value = previewData?.vehicle || null; vehicle.value = previewData?.vehicle || null;
session.value = previewData?.session || session.value; session.value = previewData?.session || session.value;
if (!previewData?.session?.id) {
summary.value = null;
events.value = [];
}
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) { if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
configVersionId.value = previewData?.config_version_id ?? null; configVersionId.value = previewData?.config_version_id ?? null;
} }
@@ -571,7 +463,6 @@ export function useSelfServeLogic() {
questions.value = nextQuestions; questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions); answers.value = buildAnswersMap(nextQuestions);
setSummaryVisibleQuestions(nextQuestions);
const normalizedConditions = Array.isArray(previewData?.conditions) const normalizedConditions = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition) ? previewData.conditions.map(normalizeCondition)
@@ -591,21 +482,19 @@ export function useSelfServeLogic() {
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority) ? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
: []; : [];
tasks.value = normalizedTasks;
applyAllowedServices(previewData);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks); const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return false; return false;
} }
tasks.value = hydratedTasks; tasks.value = hydratedTasks;
applyAllowedServices(previewData);
return true; return true;
}; };
const applySummaryData = async (summaryData, options = {}) => { const applySummaryData = async (summaryData, options = {}) => {
const { signal = null } = options; const { requestId = null } = options;
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return false; return false;
} }
@@ -644,10 +533,9 @@ export function useSelfServeLogic() {
const normalizedTasks = summaryData.tasks const normalizedTasks = summaryData.tasks
.map(normalizeTask) .map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority); .sort((a, b) => a.order_priority - b.order_priority);
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks); const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return false; return false;
} }
@@ -677,7 +565,7 @@ export function useSelfServeLogic() {
}; };
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => { const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
const { signal = null } = options; const { requestId = null } = options;
if (!params.session_id && !(params.lane_id && params.reg)) { if (!params.session_id && !(params.lane_id && params.reg)) {
return null; return null;
} }
@@ -687,39 +575,30 @@ export function useSelfServeLogic() {
} }
try { try {
const summaryData = await requestWashSummary(params, signal); const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
const didApply = await applySummaryData(summaryData, { signal }); const didApply = await applySummaryData(summaryData, { requestId });
if (!didApply) { if (!didApply) {
return null; return null;
} }
requestError.value = null; requestError.value = null;
return summaryData; return summaryData;
} catch (error) { } catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve summary:", error); console.error("Error fetching self-serve summary:", error);
if (isFetchSignalActive(signal)) { if (isFetchRequestActive(requestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen."); requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen.");
} }
return null; return null;
} finally { } finally {
if (manageLoading && isFetchSignalActive(signal)) { if (manageLoading) {
loading.value = false; loading.value = false;
} }
} }
}; };
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => { const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg); const requestId = beginFetchRequest();
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
if (!laneId || !reg || reg.trim().length < 2) { if (!laneId || !reg || reg.trim().length < 2) {
abortActiveFetchRequest();
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null; preview.value = null;
summary.value = null; summary.value = null;
session.value = null; session.value = null;
@@ -745,11 +624,8 @@ export function useSelfServeLogic() {
return null; return null;
} }
const abortController = beginFetchRequest(options.signal || null);
const signal = abortController?.signal || null;
loading.value = true; loading.value = true;
requestError.value = null; requestError.value = null;
inFlightFetchKey.value = fetchKey;
try { try {
const normalizedReg = reg.trim().toUpperCase(); const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId); const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -764,18 +640,18 @@ export function useSelfServeLogic() {
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`; const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey; const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
const previewData = hasVehicleTypeOverride const previewData = hasVehicleTypeOverride
? await requestPreviewAllowed(laneId, normalizedReg, normalizedVehicleTypeId, signal) ? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
: await requestPreviewAllowed(laneId, normalizedReg, null, signal); : await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return null; return null;
} }
const didApplyPreview = await applyPreviewData(previewData, { const didApplyPreview = await applyPreviewData(previewData, {
mergeQuestions: shouldMergeQuestions, mergeQuestions: shouldMergeQuestions,
signal, requestId,
}); });
if (!didApplyPreview || !isFetchSignalActive(signal)) { if (!didApplyPreview || !isFetchRequestActive(requestId)) {
return null; return null;
} }
@@ -790,11 +666,11 @@ export function useSelfServeLogic() {
summaryData = await fetchWashSummary( summaryData = await fetchWashSummary(
{ session_id: previewData.session.id }, { session_id: previewData.session.id },
false, false,
{ signal } { requestId }
); );
} }
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return null; return null;
} }
@@ -816,9 +692,9 @@ export function useSelfServeLogic() {
const fallbackSummary = await fetchWashSummary( const fallbackSummary = await fetchWashSummary(
fallbackSummaryParams, fallbackSummaryParams,
false, false,
{ signal } { requestId }
); );
if (!isFetchSignalActive(signal)) { if (!isFetchRequestActive(requestId)) {
return null; return null;
} }
@@ -827,31 +703,19 @@ export function useSelfServeLogic() {
} }
// If summary is not available yet, keep the preview list visible so the Questions step is not empty. // If summary is not available yet, keep the preview list visible so the Questions step is not empty.
if (!hasSummaryQuestions && isFetchSignalActive(signal)) { if (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
setSummaryVisibleQuestions(questions.value); setSummaryVisibleQuestions(questions.value);
} }
if (isFetchSignalActive(signal)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData; return previewData;
} catch (error) { } catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve preview:", error); console.error("Error fetching self-serve preview:", error);
if (isFetchSignalActive(signal)) { if (isFetchRequestActive(requestId)) {
requestError.value = extractErrorMessage(error); requestError.value = extractErrorMessage(error);
} }
return null; return null;
} finally { } finally {
const isCurrentFetch = !abortController || activeFetchAbortController.value === abortController; if (isFetchRequestActive(requestId)) {
if (isCurrentFetch) {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
activeFetchAbortController.value = null;
loading.value = false; loading.value = false;
} }
} }
@@ -862,7 +726,6 @@ export function useSelfServeLogic() {
return null; return null;
} }
const mutationRequestId = beginMutationRequest();
loading.value = true; loading.value = true;
try { try {
const normalizedReg = reg.trim().toUpperCase(); const normalizedReg = reg.trim().toUpperCase();
@@ -890,10 +753,6 @@ export function useSelfServeLogic() {
const payload = response?.data?.data || response?.data || response || {}; const payload = response?.data?.data || response?.data || response || {};
const responseSummary = payload?.selfserve || null; const responseSummary = payload?.selfserve || null;
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
answers.value = { answers.value = {
...answers.value, ...answers.value,
[parseInt(questionId)]: value, [parseInt(questionId)]: value,
@@ -910,10 +769,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session); updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
} }
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true }); await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
answers.value = { answers.value = {
...answers.value, ...answers.value,
[parseInt(questionId)]: value, [parseInt(questionId)]: value,
@@ -921,14 +777,10 @@ export function useSelfServeLogic() {
return payload; return payload;
} catch (error) { } catch (error) {
console.error("Error synchronizing vehicle answer:", error); console.error("Error synchronizing vehicle answer:", error);
if (isMutationRequestActive(mutationRequestId)) { requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
}
throw error; throw error;
} finally { } finally {
if (isMutationRequestActive(mutationRequestId)) { loading.value = false;
loading.value = false;
}
} }
}; };
@@ -937,7 +789,6 @@ export function useSelfServeLogic() {
return { deletedCount: 0 }; return { deletedCount: 0 };
} }
const mutationRequestId = beginMutationRequest();
loading.value = true; loading.value = true;
try { try {
const normalizedReg = reg.trim().toUpperCase(); const normalizedReg = reg.trim().toUpperCase();
@@ -973,10 +824,6 @@ export function useSelfServeLogic() {
SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId) SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId)
))); )));
if (!isMutationRequestActive(mutationRequestId)) {
return { deletedCount: conditionIdsToDelete.length };
}
// Force a non-merge refresh so cleared answers are not kept from local state. // Force a non-merge refresh so cleared answers are not kept from local state.
lastPreviewContextKey.value = null; lastPreviewContextKey.value = null;
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
@@ -987,19 +834,15 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId ? normalizedRefreshVehicleTypeId
: null; : null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true }); await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
return { deletedCount: conditionIdsToDelete.length }; return { deletedCount: conditionIdsToDelete.length };
} catch (error) { } catch (error) {
console.error("Error clearing self-serve answers:", error); console.error("Error clearing self-serve answers:", error);
if (isMutationRequestActive(mutationRequestId)) { requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
}
throw error; throw error;
} finally { } finally {
if (isMutationRequestActive(mutationRequestId)) { loading.value = false;
loading.value = false;
}
} }
}; };
@@ -1161,27 +1004,9 @@ export function useSelfServeLogic() {
return activeTaskServices.value.includes(serviceName); return activeTaskServices.value.includes(serviceName);
}; };
const updateLaneAllowedServices = async (laneId, options = {}) => { const updateLaneAllowedServices = async (laneId) => {
if (!laneId) return; if (!laneId) return;
const hasTaskIdsOverride = Array.isArray(options.taskIds); const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const activeTaskIds = hasTaskIdsOverride
? options.taskIds.map((taskId) => parseInt(taskId)).filter(Boolean)
: activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const taskIds = hasTaskIdsOverride || activeTaskIds.length > 0
? activeTaskIds
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
if (taskIds.length === 0) {
requestError.value = null;
return {
success: true,
data: {
lane_id: parseInt(laneId),
allowed_services: allowedServices.value,
skipped_empty_task_sync: true,
},
};
}
try { try {
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', { const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
@@ -1227,12 +1052,6 @@ export function useSelfServeLogic() {
} }
}; };
if (getCurrentInstance()) {
onUnmounted(() => {
abortActiveFetchRequest();
});
}
return { return {
loading, loading,
error: requestError, error: requestError,
@@ -1250,7 +1069,6 @@ export function useSelfServeLogic() {
answers, answers,
completedTasks, completedTasks,
allowedServices, allowedServices,
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
configVersionId, configVersionId,
evaluationTrace, evaluationTrace,
visibleQuestions, visibleQuestions,
@@ -1262,7 +1080,52 @@ export function useSelfServeLogic() {
allowed, allowed,
fetchSelfServeData, fetchSelfServeData,
fetchWashSummary, fetchWashSummary,
cancelSelfServeFetch: abortActiveFetchRequest, syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
};
});
export function useSelfServeLogic(options = {}) {
const pinia = ensureSelfServePinia();
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
const store = createSelfServeLogicStore(storeId)(pinia);
const refs = storeToRefs(store);
const {
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
} = store;
return {
...refs,
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer, syncVehicleAnswer,
clearVehicleAnswers, clearVehicleAnswers,
evaluateRule, evaluateRule,
+5 -40
View File
@@ -14,9 +14,6 @@ const isLaneSelfServeEnabled = (lane) =>
) )
); );
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const toDepartmentViewModel = (department, distance = null) => ({ const toDepartmentViewModel = (department, distance = null) => ({
id: department.id, id: department.id,
distance, distance,
@@ -50,7 +47,6 @@ export function useWashDepartments(options = {}) {
const isSearchingDepartments = ref(false); const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null); const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null); const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null; let refreshInterval = null;
@@ -77,12 +73,6 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true; return nearestDepartment.value.self_serve_enabled === true;
}); });
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {}); const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => { const fetchDepartments = async () => {
@@ -120,30 +110,20 @@ export function useWashDepartments(options = {}) {
const forcedDepartment = getForcedDepartment(); const forcedDepartment = getForcedDepartment();
if (forcedDepartment) { if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment); nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value; return nearestDepartment.value;
} }
} }
if (guestDepartments.value.length === 0) {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
return nearestDepartment.value;
}
if (!hasLocationCoordinates(locationValue)) {
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
return nearestDepartment.value;
}
let currentNearestDepartment = { let currentNearestDepartment = {
id: null, id: null,
distance: Infinity, distance: Infinity,
}; };
guestDepartments.value.forEach((department) => { guestDepartments.value.forEach((department) => {
if (!locationValue?.coords) {
return;
}
const from = { const from = {
latitude: locationValue.coords.latitude, latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude, longitude: locationValue.coords.longitude,
@@ -161,18 +141,6 @@ export function useWashDepartments(options = {}) {
if (currentNearestDepartment.id) { if (currentNearestDepartment.id) {
nearestDepartment.value = currentNearestDepartment; nearestDepartment.value = currentNearestDepartment;
departmentSelectionStrategy.value = "distance";
return nearestDepartment.value;
}
const fallbackDepartment =
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
if (fallbackDepartment) {
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
departmentSelectionStrategy.value = "fallback";
} else {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
} }
return nearestDepartment.value; return nearestDepartment.value;
@@ -199,7 +167,7 @@ export function useWashDepartments(options = {}) {
}; };
const startDepartmentSearch = () => { const startDepartmentSearch = () => {
if (!canAccessSuperUser() && hasLocationCoordinates()) { if (!canAccessSuperUser()) {
return; return;
} }
@@ -277,9 +245,6 @@ export function useWashDepartments(options = {}) {
isSearchingDepartments, isSearchingDepartments,
lastDepartmentFetchTime, lastDepartmentFetchTime,
departmentFetchError, departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds, availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled, doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments, fetchDepartments,
+11 -28
View File
@@ -7,11 +7,6 @@ export const WASH_STEPS = {
COMPLETED: 5, COMPLETED: 5,
}; };
const normalizeLaneId = (value) => {
const normalized = parseInt(String(value ?? ""), 10);
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
};
export function useWashFlowState(options) { export function useWashFlowState(options) {
const { const {
currentStep, currentStep,
@@ -19,7 +14,7 @@ export function useWashFlowState(options) {
customerNumberInput, customerNumberInput,
licensePlateInput, licensePlateInput,
vehicleTypeSelect, vehicleTypeSelect,
isCustomerNumberRequired, availableProductIds,
radioLaneOption, radioLaneOption,
nearestDepartment, nearestDepartment,
allVisibleQuestionsAnswered, allVisibleQuestionsAnswered,
@@ -39,17 +34,6 @@ export function useWashFlowState(options) {
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
); );
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
const getSelectedLane = () => {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId === null) {
return null;
}
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
};
const clickableSteps = { const clickableSteps = {
[steps.VEHICLE]: () => { [steps.VEHICLE]: () => {
if (washInProgress.value) { if (washInProgress.value) {
@@ -57,15 +41,16 @@ export function useWashFlowState(options) {
} }
const selectedVehicleTypeId = vehicleTypeSelect.value; const selectedVehicleTypeId = vehicleTypeSelect.value;
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined; const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const customerNumberValue = customerNumberInput.value; const customerNumberValue = customerNumberInput.value;
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false; const customerNumberValid = customerNumberValue === null
const customerNumberValid = !requiresCustomerNumber
|| customerNumberValue === null
|| customerNumberValue === undefined || customerNumberValue === undefined
|| String(customerNumberValue).trim() !== ""; || String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== ""); const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasSelectedVehicleType) { if (!hasAllowedVehicleType) {
return false; return false;
} }
@@ -118,7 +103,7 @@ export function useWashFlowState(options) {
} }
if (currentStep.value === steps.SELECT_LANE) { if (currentStep.value === steps.SELECT_LANE) {
const selectedLane = getSelectedLane(); const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
if (!selectedLane || !isLaneAvailable(selectedLane)) { if (!selectedLane || !isLaneAvailable(selectedLane)) {
return true; return true;
} }
@@ -148,10 +133,9 @@ export function useWashFlowState(options) {
const handleConfirmNext = async () => { const handleConfirmNext = async () => {
if (currentStep.value === steps.QUESTIONS) { if (currentStep.value === steps.QUESTIONS) {
editAnswers.value = false; editAnswers.value = false;
const selectedLaneId = getSelectedLaneId(); if (radioLaneOption.value && radioLaneOption.value !== "Any") {
if (selectedLaneId !== null) {
try { try {
await updateLaneAllowedServices(selectedLaneId); await updateLaneAllowedServices(radioLaneOption.value);
} catch { } catch {
return; return;
} }
@@ -166,9 +150,8 @@ export function useWashFlowState(options) {
return; return;
} }
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
await onStartWash( await onStartWash(
selectedLaneId, radioLaneOption.value,
licensePlateInput.value, licensePlateInput.value,
customerNumberInput.value, customerNumberInput.value,
targetStepForStart() targetStepForStart()
+3 -8
View File
@@ -70,10 +70,7 @@ export function useWashSessionActions(options) {
const isAlreadyStoppedStopError = (message) => { const isAlreadyStoppedStopError = (message) => {
const normalized = String(message || "").toLowerCase(); const normalized = String(message || "").toLowerCase();
return ( return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
normalized.includes("not occupied") &&
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
);
}; };
const clearActiveWashState = () => { const clearActiveWashState = () => {
@@ -250,18 +247,16 @@ export function useWashSessionActions(options) {
return false; return false;
} }
const selectedWashType = radioWashType.value === "Machine" ? "Machine" : "Manual";
const startResponse = await executeSelfServeCommand(laneId, "START", { const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber), customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(), license_plate: licensePlate.trim().toUpperCase(),
wash_type: selectedWashType, defer_relay_side_effects: false,
defer_relay_side_effects: true,
}); });
if (!startResponse) { if (!startResponse) {
return false; return false;
} }
if (selectedWashType === "Machine" && isServiceAllowed("MACHINE")) { if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null; let machineRelayResponse = null;
try { try {
machineRelayResponse = await enableMachineRelay(laneId); machineRelayResponse = await enableMachineRelay(laneId);
-1
View File
@@ -66,7 +66,6 @@ export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
export const ALLOWED_ORIGINS = [ export const ALLOWED_ORIGINS = [
"https://truckwash.io", "https://truckwash.io",
"https://www.truckwash.io", "https://www.truckwash.io",
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
"http://localhost:5173", "http://localhost:5173",
"http://localhost:4173", "http://localhost:4173",
"http://localhost:4174", "http://localhost:4174",
+1 -34
View File
@@ -1845,21 +1845,6 @@
"week": "@:{'templates.generated.compat.common.week'}" "week": "@:{'templates.generated.compat.common.week'}"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Flyt til kunde",
"confirm_button": "Flyt",
"confirm_text": "Flyt fakturasamling #{id} og {count} ordre til kunde #{customer}?",
"confirm_title": "Bekræft flytning",
"current_customer": "Nuværende kunde: #{customer}",
"customer_value": "Kunde #{customer}",
"error_title": "Kunne ikke flytte fakturasamling",
"invalid_customer": "Indtast et gyldigt kundenummer.",
"locked": "Låst",
"same_customer": "Vælg en anden kunde end den nuværende.",
"success_text": "{count} ordre blev flyttet til kunde #{customer}.",
"success_title": "Fakturasamling flyttet",
"title": "Flyt fakturasamling"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bogført'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.ingen'} @:{'words.generated.yderligere'} @:{'words.generated.handlinger'} @:{'words.generated.er'} @:{'words.generated.nødvendige'}.", "booked_desc": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bogført'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.ingen'} @:{'words.generated.yderligere'} @:{'words.generated.handlinger'} @:{'words.generated.er'} @:{'words.generated.nødvendige'}.",
"booked_with_economic": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bogført'} @:{'words.generated.med'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}", "booked_with_economic": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bogført'} @:{'words.generated.med'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2330,15 +2315,6 @@
"delete_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'}, @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.slette'} @:{'words.generated.dette'} @:{'words.generated.produkt'}?", "delete_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'}, @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.slette'} @:{'words.generated.dette'} @:{'words.generated.produkt'}?",
"edit": "@:{'templates.generated.compat.products.edit_product'}" "edit": "@:{'templates.generated.compat.products.edit_product'}"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL til kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
"notification_settings": "Notifikationer",
"notification_settings_desc": "Slack-webhooks til systemhændelser.",
"subtitle": "Konfiguration af Slack-notifikationer",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
"shelly": { "shelly": {
"api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nøkkelen'} @:{'words.generated.bruges'} @:{'words.generated.til'} @:{'words.generated.a_2'} @:{'words.generated.autentisere'} @:{'words.generated.shelly'}-@:{'words.generated.integrationen'}", "api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nøkkelen'} @:{'words.generated.bruges'} @:{'words.generated.til'} @:{'words.generated.a_2'} @:{'words.generated.autentisere'} @:{'words.generated.shelly'}-@:{'words.generated.integrationen'}",
"api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.forbindelsesindstillingerne'}.", "api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.forbindelsesindstillingerne'}.",
@@ -3808,13 +3784,6 @@
"unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.scannede'} @:{'words.generated.qr'} @:{'words.generated.kode'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.gyldig'} @:{'words.generated.for'} @:{'words.generated.denne'} @:{'words.generated.side'}.", "unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.scannede'} @:{'words.generated.qr'} @:{'words.generated.kode'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.gyldig'} @:{'words.generated.for'} @:{'words.generated.denne'} @:{'words.generated.side'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.ukendt'} @:{'words.generated.qr'} @:{'words.generated.kode'}" "unknown_qr_title": "@.capitalize:{'words.generated.ukendt'} @:{'words.generated.qr'} @:{'words.generated.kode'}"
}, },
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlaes nu?",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
"version": "Nuværende {current} / seneste {latest}"
},
"messages": { "messages": {
"are_you_sure": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?", "are_you_sure": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?",
"cannot_undo": "@.capitalize:{'words.generated.denne'} @:{'words.generated.handling'} @:{'words.generated.kan'} @:{'words.generated.ikke'} @:{'words.generated.fortrydes'}.", "cannot_undo": "@.capitalize:{'words.generated.denne'} @:{'words.generated.handling'} @:{'words.generated.kan'} @:{'words.generated.ikke'} @:{'words.generated.fortrydes'}.",
@@ -5509,9 +5478,7 @@
"email_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.e'}-mails @:{'words.generated.med'} bookingbekræftelser @:{'words.generated.og'} @:{'words.generated.vaskecertifikater'}.", "email_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.e'}-mails @:{'words.generated.med'} bookingbekræftelser @:{'words.generated.og'} @:{'words.generated.vaskecertifikater'}.",
"sms_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'} @:{'words.generated.ved'} @:{'words.generated.forskellige'} @:{'words.generated.begivenheder'}.", "sms_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'} @:{'words.generated.ved'} @:{'words.generated.forskellige'} @:{'words.generated.begivenheder'}.",
"sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.til'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}", "sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.til'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}",
"sms_phone_desc": "@.capitalize:{'words.generated.det'} @:{'words.generated.telefonnummer'}, @:{'words.generated.der'} @:{'words.generated.modtager'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}.", "sms_phone_desc": "@.capitalize:{'words.generated.det'} @:{'words.generated.telefonnummer'}, @:{'words.generated.der'} @:{'words.generated.modtager'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}."
"superuser_notifications": "Superuser-notifikationer",
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden."
}, },
"security": { "security": {
"description": "@.capitalize:{'words.generated.dine'} sikkerhedsindstillinger", "description": "@.capitalize:{'words.generated.dine'} sikkerhedsindstillinger",
+1 -34
View File
@@ -1955,21 +1955,6 @@
"week": "Uke" "week": "Uke"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.rechnung'} @:{'words.generated.ist'} @:{'words.generated.in'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}, @:{'words.generated.keine'} @:{'words.generated.weiteren'} @:{'words.generated.aktionen'} @:{'words.generated.sind'} @:{'words.generated.erforderlich'}.", "booked_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.rechnung'} @:{'words.generated.ist'} @:{'words.generated.in'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}, @:{'words.generated.keine'} @:{'words.generated.weiteren'} @:{'words.generated.aktionen'} @:{'words.generated.sind'} @:{'words.generated.erforderlich'}.",
"booked_with_economic": "@:{'words.generated.rechnung'} @:{'words.generated.mit'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}", "booked_with_economic": "@:{'words.generated.rechnung'} @:{'words.generated.mit'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}",
@@ -2440,15 +2425,6 @@
"delete_confirm": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}, @:{'words.generated.dass'} @.capitalize:{'words.generated.sie'} @:{'words.generated.dieses'} @.capitalize:{'words.generated.produkt'} @:{'words.generated.l'}?@:{'words.generated.schen'} @:{'words.generated.m'}?@:{'words.generated.chten'}?", "delete_confirm": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}, @:{'words.generated.dass'} @.capitalize:{'words.generated.sie'} @:{'words.generated.dieses'} @.capitalize:{'words.generated.produkt'} @:{'words.generated.l'}?@:{'words.generated.schen'} @:{'words.generated.m'}?@:{'words.generated.chten'}?",
"edit": "@.capitalize:{'words.generated.produkt'} @:{'words.generated.bearbeiten'}" "edit": "@.capitalize:{'words.generated.produkt'} @:{'words.generated.bearbeiten'}"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL fuer Kundenregistrierungen",
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
"notification_settings": "Benachrichtigungseinstellungen",
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
"title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
"shelly": { "shelly": {
"api_key_desc": "@.capitalize:{'words.generated.der'} @:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.schl'}?@:{'words.generated.ssel'} @:{'words.generated.wird'} @:{'words.generated.zur'} @:{'words.generated.authentifizierung'} @:{'words.generated.der'} @:{'words.generated.shelly'}-@.capitalize:{'words.generated.integration'} @:{'words.generated.verwendet'}", "api_key_desc": "@.capitalize:{'words.generated.der'} @:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.schl'}?@:{'words.generated.ssel'} @:{'words.generated.wird'} @:{'words.generated.zur'} @:{'words.generated.authentifizierung'} @:{'words.generated.der'} @:{'words.generated.shelly'}-@.capitalize:{'words.generated.integration'} @:{'words.generated.verwendet'}",
"api_settings_desc": "@:{'words.generated.api'}-@:{'words.generated.verbindungseinstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.shelly'}.", "api_settings_desc": "@:{'words.generated.api'}-@:{'words.generated.verbindungseinstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.shelly'}.",
@@ -3918,13 +3894,6 @@
"unknown_qr_text": "@.capitalize:{'words.generated.der'} gescannte @:{'words.generated.qr'}-@:{'words.generated.code'} @:{'words.generated.ist'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.diese'} @:{'words.generated.seite'} @:{'words.generated.nicht'} @:{'words.generated.g'}?@:{'words.generated.ltig'}.", "unknown_qr_text": "@.capitalize:{'words.generated.der'} gescannte @:{'words.generated.qr'}-@:{'words.generated.code'} @:{'words.generated.ist'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.diese'} @:{'words.generated.seite'} @:{'words.generated.nicht'} @:{'words.generated.g'}?@:{'words.generated.ltig'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.unbekannter'} @:{'words.generated.qr'}-@:{'words.generated.code'}" "unknown_qr_title": "@.capitalize:{'words.generated.unbekannter'} @:{'words.generated.qr'}-@:{'words.generated.code'}"
}, },
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
"version": "Aktuell {current} / neueste {latest}"
},
"messages": { "messages": {
"are_you_sure": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}?", "are_you_sure": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}?",
"cannot_undo": "@.capitalize:{'words.generated.diese'} @:{'words.generated.aktion'} @:{'words.generated.kann'} @:{'words.generated.nicht'} @:{'words.generated.r'}?@:{'words.generated.ckg'}?@:{'words.generated.ngig'} @:{'words.generated.gemacht'} @:{'words.generated.werden'}.", "cannot_undo": "@.capitalize:{'words.generated.diese'} @:{'words.generated.aktion'} @:{'words.generated.kann'} @:{'words.generated.nicht'} @:{'words.generated.r'}?@:{'words.generated.ckg'}?@:{'words.generated.ngig'} @:{'words.generated.gemacht'} @:{'words.generated.werden'}.",
@@ -5619,9 +5588,7 @@
"email_notifications_desc": "@.upper:{'words.generated.e'}-@:{'words.generated.mails'} @:{'words.generated.mit'} @:{'words.generated.buchungsbest'}?@:{'words.generated.tigungen'} @:{'words.generated.und'} Waschzertifikaten @:{'words.generated.erhalten'}.", "email_notifications_desc": "@.upper:{'words.generated.e'}-@:{'words.generated.mails'} @:{'words.generated.mit'} @:{'words.generated.buchungsbest'}?@:{'words.generated.tigungen'} @:{'words.generated.und'} Waschzertifikaten @:{'words.generated.erhalten'}.",
"sms_notifications_desc": "@:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.verschiedene'} @:{'words.generated.ereignisse'} @:{'words.generated.erhalten'}.", "sms_notifications_desc": "@:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.verschiedene'} @:{'words.generated.ereignisse'} @:{'words.generated.erhalten'}.",
"sms_phone": "@:{'words.generated.telefonnummer'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'}", "sms_phone": "@:{'words.generated.telefonnummer'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'}",
"sms_phone_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.telefonnummer'}, @:{'words.generated.die'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.erh'}?@:{'words.generated.lt'}.", "sms_phone_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.telefonnummer'}, @:{'words.generated.die'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.erh'}?@:{'words.generated.lt'}."
"superuser_notifications": "Superuser-Benachrichtigungen",
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert."
}, },
"security": { "security": {
"description": "@.capitalize:{'words.generated.ihre'} Sicherheitseinstellungen", "description": "@.capitalize:{'words.generated.ihre'} Sicherheitseinstellungen",
+1 -34
View File
@@ -1679,21 +1679,6 @@
"week": "@:{'templates.generated.compat.common.week'}" "week": "@:{'templates.generated.compat.common.week'}"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.invoice'} @:{'words.generated.is'} @:{'words.generated.booked'} @:{'words.generated.in'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.no'} @:{'words.generated.further'} @:{'words.generated.actions'} @:{'words.generated.are'} @:{'words.generated.required'}.", "booked_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.invoice'} @:{'words.generated.is'} @:{'words.generated.booked'} @:{'words.generated.in'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.no'} @:{'words.generated.further'} @:{'words.generated.actions'} @:{'words.generated.are'} @:{'words.generated.required'}.",
"booked_with_economic": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.booked'} @:{'words.generated.with'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}", "booked_with_economic": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.booked'} @:{'words.generated.with'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2164,15 +2149,6 @@
"delete_confirm": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'} @:{'words.generated.you'} @:{'words.generated.want'} @:{'words.generated.to'} @:{'words.generated.delete'} @:{'words.generated.this'} @:{'words.generated.product'}?", "delete_confirm": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'} @:{'words.generated.you'} @:{'words.generated.want'} @:{'words.generated.to'} @:{'words.generated.delete'} @:{'words.generated.this'} @:{'words.generated.product'}?",
"edit": "@:{'templates.generated.compat.products.edit_product'}" "edit": "@:{'templates.generated.compat.products.edit_product'}"
}, },
"slack": {
"customer_registration_webhook_url": "Customer registration webhook URL",
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
"notification_settings": "Notification settings",
"notification_settings_desc": "Slack webhooks for system events.",
"subtitle": "Configuration of Slack notifications",
"title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release."
},
"shelly": { "shelly": {
"api_key_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.key'} @:{'words.generated.is'} @:{'words.generated.used'} @:{'words.generated.to'} @:{'words.generated.authenticate'} @:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.integration'}", "api_key_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.key'} @:{'words.generated.is'} @:{'words.generated.used'} @:{'words.generated.to'} @:{'words.generated.authenticate'} @:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.integration'}",
"api_settings_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.connection'} @:{'words.generated.settings'}.", "api_settings_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.connection'} @:{'words.generated.settings'}.",
@@ -3642,13 +3618,6 @@
"unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.scanned'} @:{'words.generated.qr'} @:{'words.generated.code'} @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.valid'} @:{'words.generated.for'} @:{'words.generated.this'} @:{'words.generated.page'}.", "unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.scanned'} @:{'words.generated.qr'} @:{'words.generated.code'} @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.valid'} @:{'words.generated.for'} @:{'words.generated.this'} @:{'words.generated.page'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.unknown'} @:{'words.generated.qr'} @.capitalize:{'words.generated.code'}" "unknown_qr_title": "@.capitalize:{'words.generated.unknown'} @:{'words.generated.qr'} @.capitalize:{'words.generated.code'}"
}, },
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
"version": "Current {current} / latest {latest}"
},
"messages": { "messages": {
"are_you_sure": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'}?", "are_you_sure": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'}?",
"cannot_undo": "@.capitalize:{'words.generated.this'} decision @:{'words.generated.cannot'} @:{'words.generated.be'} @:{'words.generated.undone'}.", "cannot_undo": "@.capitalize:{'words.generated.this'} decision @:{'words.generated.cannot'} @:{'words.generated.be'} @:{'words.generated.undone'}.",
@@ -5343,9 +5312,7 @@
"email_notifications_desc": "@.capitalize:{'words.generated.receive'} @:{'words.generated.emails'} @:{'words.generated.with'} @:{'words.generated.booking'} @:{'words.generated.confirmations'} @:{'words.generated.and'} @:{'words.generated.wash'} @:{'words.generated.certificates'}.", "email_notifications_desc": "@.capitalize:{'words.generated.receive'} @:{'words.generated.emails'} @:{'words.generated.with'} @:{'words.generated.booking'} @:{'words.generated.confirmations'} @:{'words.generated.and'} @:{'words.generated.wash'} @:{'words.generated.certificates'}.",
"sms_notifications_desc": "@.capitalize:{'words.generated.receive'} @:{'words.generated.sms'} @:{'words.generated.notifications'} @:{'words.generated.for'} @:{'words.generated.various'} @:{'words.generated.events'}.", "sms_notifications_desc": "@.capitalize:{'words.generated.receive'} @:{'words.generated.sms'} @:{'words.generated.notifications'} @:{'words.generated.for'} @:{'words.generated.various'} @:{'words.generated.events'}.",
"sms_phone": "@.capitalize:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.for'} @:{'words.generated.sms'} @:{'words.generated.notifications'}", "sms_phone": "@.capitalize:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.for'} @:{'words.generated.sms'} @:{'words.generated.notifications'}",
"sms_phone_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.that'} @:{'words.generated.receives'} @:{'words.generated.sms'} @:{'words.generated.notifications'}.", "sms_phone_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.that'} @:{'words.generated.receives'} @:{'words.generated.sms'} @:{'words.generated.notifications'}."
"superuser_notifications": "Superuser notifications",
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site."
}, },
"security": { "security": {
"description": "@.capitalize:{'words.generated.your'} @:{'words.generated.security'} @:{'words.generated.settings'}", "description": "@.capitalize:{'words.generated.your'} @:{'words.generated.security'} @:{'words.generated.settings'}",
-34
View File
@@ -892,21 +892,6 @@
"year": "@:common.year" "year": "@:common.year"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "@:{'templates.generated.compat.collected_invoice.move_customer.card_title'}",
"confirm_button": "@:{'templates.generated.compat.collected_invoice.move_customer.confirm_button'}",
"confirm_text": "@:{'templates.generated.compat.collected_invoice.move_customer.confirm_text'}",
"confirm_title": "@:{'templates.generated.compat.collected_invoice.move_customer.confirm_title'}",
"current_customer": "@:{'templates.generated.compat.collected_invoice.move_customer.current_customer'}",
"customer_value": "@:{'templates.generated.compat.collected_invoice.move_customer.customer_value'}",
"error_title": "@:{'templates.generated.compat.collected_invoice.move_customer.error_title'}",
"invalid_customer": "@:{'templates.generated.compat.collected_invoice.move_customer.invalid_customer'}",
"locked": "@:{'templates.generated.compat.collected_invoice.move_customer.locked'}",
"same_customer": "@:{'templates.generated.compat.collected_invoice.move_customer.same_customer'}",
"success_text": "@:{'templates.generated.compat.collected_invoice.move_customer.success_text'}",
"success_title": "@:{'templates.generated.compat.collected_invoice.move_customer.success_title'}",
"title": "@:{'templates.generated.compat.collected_invoice.move_customer.title'}"
},
"economic": { "economic": {
"booked_desc": "@:{'templates.generated.compat.collected_invoice.economic.booked_desc'}", "booked_desc": "@:{'templates.generated.compat.collected_invoice.economic.booked_desc'}",
"booked_with_economic": "@:{'templates.generated.compat.collected_invoice.economic.booked_with_economic'}", "booked_with_economic": "@:{'templates.generated.compat.collected_invoice.economic.booked_with_economic'}",
@@ -1362,15 +1347,6 @@
"subtitle": "@:products.subtitle", "subtitle": "@:products.subtitle",
"title": "@:common.products" "title": "@:common.products"
}, },
"slack": {
"customer_registration_webhook_url": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url'}",
"customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}",
"subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}",
"title": "@:{'templates.generated.compat.configuration.slack.title'}",
"unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}"
},
"shelly": { "shelly": {
"api_key": "@:configuration.limble.api_key", "api_key": "@:configuration.limble.api_key",
"api_key_desc": "@:{'templates.generated.compat.configuration.shelly.api_key_desc'}", "api_key_desc": "@:{'templates.generated.compat.configuration.shelly.api_key_desc'}",
@@ -3154,13 +3130,6 @@
"unknown_qr_text": "@:{'templates.generated.compat.login_qr.unknown_qr_text'}", "unknown_qr_text": "@:{'templates.generated.compat.login_qr.unknown_qr_text'}",
"unknown_qr_title": "@:{'templates.generated.compat.login_qr.unknown_qr_title'}" "unknown_qr_title": "@:{'templates.generated.compat.login_qr.unknown_qr_title'}"
}, },
"maintenance_menu": {
"confirm_clear_local": "@:{'templates.generated.compat.maintenance_menu.confirm_clear_local'}",
"force_update_clear_hint": "@:{'templates.generated.compat.maintenance_menu.force_update_clear_hint'}",
"force_update_clear": "@:{'templates.generated.compat.maintenance_menu.force_update_clear'}",
"title": "@:{'templates.generated.compat.maintenance_menu.title'}",
"version": "@:{'templates.generated.compat.maintenance_menu.version'}"
},
"messages": { "messages": {
"are_you_sure": "@:{'templates.generated.compat.messages.are_you_sure'}", "are_you_sure": "@:{'templates.generated.compat.messages.are_you_sure'}",
"cancelled": "@:{'templates.generated.compat.global.cancelled'}", "cancelled": "@:{'templates.generated.compat.global.cancelled'}",
@@ -4762,7 +4731,6 @@
"department_relays": "@:{'templates.generated.compat.superuser.nav.department_relays'}", "department_relays": "@:{'templates.generated.compat.superuser.nav.department_relays'}",
"departments": "@:common.departments", "departments": "@:common.departments",
"employees": "@:{'templates.generated.compat.common.roless.employee'}", "employees": "@:{'templates.generated.compat.common.roless.employee'}",
"error_reports": "@:{'templates.generated.compat.superuser.nav.error_reports'}",
"invoices": "@:common.invoices", "invoices": "@:common.invoices",
"invoicing": "@:{'templates.generated.compat.superuser.nav.invoicing'}", "invoicing": "@:{'templates.generated.compat.superuser.nav.invoicing'}",
"orders": "@:{'templates.generated.compat.superuser.nav.orders'}", "orders": "@:{'templates.generated.compat.superuser.nav.orders'}",
@@ -5587,8 +5555,6 @@
"sms_phone": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone'}", "sms_phone": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone'}",
"sms_phone_desc": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone_desc'}", "sms_phone_desc": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone_desc'}",
"sms_subtitle": "@:time_booking_flow.sms", "sms_subtitle": "@:time_booking_flow.sms",
"superuser_notifications": "@:{'templates.generated.compat.user_dashboard.profile.notifications.superuser_notifications'}",
"superuser_notifications_desc": "@:{'templates.generated.compat.user_dashboard.profile.notifications.superuser_notifications_desc'}",
"title": "@:nav.notifications" "title": "@:nav.notifications"
}, },
"security": { "security": {
+1 -34
View File
@@ -1956,21 +1956,6 @@
"week": "@:{'templates.generated.compat.common.week'}" "week": "@:{'templates.generated.compat.common.week'}"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bokført'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.ingen'} @:{'words.generated.ytterligere'} @:{'words.generated.handlinger'} @:{'words.generated.er'} @:{'words.generated.nødvendig'}.", "booked_desc": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bokført'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.ingen'} @:{'words.generated.ytterligere'} @:{'words.generated.handlinger'} @:{'words.generated.er'} @:{'words.generated.nødvendig'}.",
"booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bestilles'} @:{'words.generated.hos'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}", "booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bestilles'} @:{'words.generated.hos'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2441,15 +2426,6 @@
"delete_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'} @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.slette'} @:{'words.generated.dette'} produktet?", "delete_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'} @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.slette'} @:{'words.generated.dette'} produktet?",
"edit": "@:{'templates.generated.compat.products.edit_product'}" "edit": "@:{'templates.generated.compat.products.edit_product'}"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL for kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"subtitle": "Konfigurasjon av Slack-varsler",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
"shelly": { "shelly": {
"api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nøkkelen'} @:{'words.generated.brukes'} @:{'words.generated.til'} @:{'words.generated.a'} @:{'words.generated.autentisere'} @:{'words.generated.shelly'}-@:{'words.generated.integrasjonen'}", "api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nøkkelen'} @:{'words.generated.brukes'} @:{'words.generated.til'} @:{'words.generated.a'} @:{'words.generated.autentisere'} @:{'words.generated.shelly'}-@:{'words.generated.integrasjonen'}",
"api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.tilkoblingsinnstillingene'}.", "api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.tilkoblingsinnstillingene'}.",
@@ -3919,13 +3895,6 @@
"unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.skannede'} @:{'words.generated.qr'}-@:{'words.generated.koden'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.gyldig'} @:{'words.generated.for'} @:{'words.generated.denne'} @:{'words.generated.siden'}.", "unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.skannede'} @:{'words.generated.qr'}-@:{'words.generated.koden'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.gyldig'} @:{'words.generated.for'} @:{'words.generated.denne'} @:{'words.generated.siden'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.ukjent'} @:{'words.generated.qr'}-@:{'words.generated.kode'}" "unknown_qr_title": "@.capitalize:{'words.generated.ukjent'} @:{'words.generated.qr'}-@:{'words.generated.kode'}"
}, },
"maintenance_menu": {
"confirm_clear_local": "Fjern alle lokale appdata og last inn på nytt nå?",
"force_update_clear_hint": "Fjerner local storage, session storage, service workers og nettlesercacher.",
"force_update_clear": "Tving oppdatering og fjern alt lokalt",
"title": "Vedlikehold",
"version": "Gjeldende {current} / nyeste {latest}"
},
"messages": { "messages": {
"are_you_sure": "@:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?", "are_you_sure": "@:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?",
"cannot_undo": "@.capitalize:{'words.generated.denne'} avgjørelsen @:{'words.generated.kan'} @:{'words.generated.ikke'} omgjøres.", "cannot_undo": "@.capitalize:{'words.generated.denne'} avgjørelsen @:{'words.generated.kan'} @:{'words.generated.ikke'} omgjøres.",
@@ -5620,9 +5589,7 @@
"email_notifications_desc": "@.capitalize:{'words.generated.motta'} @:{'words.generated.e'}-@:{'words.generated.post'} @:{'words.generated.med'} @:{'words.generated.bestillingsbekreftelser'} @:{'words.generated.og'} @:{'words.generated.vaskesertifikater'}.", "email_notifications_desc": "@.capitalize:{'words.generated.motta'} @:{'words.generated.e'}-@:{'words.generated.post'} @:{'words.generated.med'} @:{'words.generated.bestillingsbekreftelser'} @:{'words.generated.og'} @:{'words.generated.vaskesertifikater'}.",
"sms_notifications_desc": "@.capitalize:{'words.generated.motta'} @:{'words.generated.sms'}-@:{'words.generated.varsler'} @:{'words.generated.for'} @:{'words.generated.ulike'} @:{'words.generated.arrangementer'}.", "sms_notifications_desc": "@.capitalize:{'words.generated.motta'} @:{'words.generated.sms'}-@:{'words.generated.varsler'} @:{'words.generated.for'} @:{'words.generated.ulike'} @:{'words.generated.arrangementer'}.",
"sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}", "sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}",
"sms_phone_desc": "@.capitalize:{'words.generated.telefonnummeret'} @:{'words.generated.som'} @:{'words.generated.mottar'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}.", "sms_phone_desc": "@.capitalize:{'words.generated.telefonnummeret'} @:{'words.generated.som'} @:{'words.generated.mottar'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}."
"superuser_notifications": "Superbrukervarsler",
"superuser_notifications_desc": "Motta e-postvarsler når en ny kunde registrerer seg på siden."
}, },
"security": { "security": {
"description": "Sikkerhetsinnstillingene @:{'words.generated.dine'}", "description": "Sikkerhetsinnstillingene @:{'words.generated.dine'}",
+1 -34
View File
@@ -2006,21 +2006,6 @@
"week": "Uke" "week": "Uke"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'words.generated.fakturan'} @:{'words.generated.ar'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.inga'} @:{'words.generated.ytterligare'} @:{'words.generated.atgarder'} @:{'words.generated.kravs'}.", "booked_desc": "@.capitalize:{'words.generated.fakturan'} @:{'words.generated.ar'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.inga'} @:{'words.generated.ytterligare'} @:{'words.generated.atgarder'} @:{'words.generated.kravs'}.",
"booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}", "booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2491,15 +2476,6 @@
"delete_confirm": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'} @:{'words.generated.pa'} @:{'words.generated.att'} @:{'words.generated.du'} @:{'words.generated.vill'} @:{'words.generated.ta'} @:{'words.generated.bort'} @:{'words.generated.denna'} @:{'words.generated.produkt'}?", "delete_confirm": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'} @:{'words.generated.pa'} @:{'words.generated.att'} @:{'words.generated.du'} @:{'words.generated.vill'} @:{'words.generated.ta'} @:{'words.generated.bort'} @:{'words.generated.denna'} @:{'words.generated.produkt'}?",
"edit": "@:{'templates.generated.compat.products.edit_product'}" "edit": "@:{'templates.generated.compat.products.edit_product'}"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL för kundregistreringar",
"customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.",
"notification_settings": "Aviseringsinställningar",
"notification_settings_desc": "Slack-webhooks för systemhändelser.",
"subtitle": "Konfiguration av Slack-aviseringar",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
},
"shelly": { "shelly": {
"api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nyckeln'} @:{'words.generated.anvands'} @:{'words.generated.for'} @:{'words.generated.att'} @:{'words.generated.autentisera'} @:{'words.generated.shelly'}-@:{'words.generated.integrationen'}", "api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nyckeln'} @:{'words.generated.anvands'} @:{'words.generated.for'} @:{'words.generated.att'} @:{'words.generated.autentisera'} @:{'words.generated.shelly'}-@:{'words.generated.integrationen'}",
"api_settings_desc": "@.capitalize:{'words.generated.anslutningsinstallningar'} @:{'words.generated.for'} @:{'words.generated.shelly'} @:{'words.generated.api'}.", "api_settings_desc": "@.capitalize:{'words.generated.anslutningsinstallningar'} @:{'words.generated.for'} @:{'words.generated.shelly'} @:{'words.generated.api'}.",
@@ -3969,13 +3945,6 @@
"unknown_qr_text": "@.capitalize:{'words.generated.the'} @:{'words.generated.scanned'} @:{'words.generated.qr'} @:{'words.generated.code'} @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.valid'} @:{'words.generated.for_2'} @:{'words.generated.this'} @:{'words.generated.page'}.", "unknown_qr_text": "@.capitalize:{'words.generated.the'} @:{'words.generated.scanned'} @:{'words.generated.qr'} @:{'words.generated.code'} @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.valid'} @:{'words.generated.for_2'} @:{'words.generated.this'} @:{'words.generated.page'}.",
"unknown_qr_title": "@:{'words.generated.okand'} @:{'words.generated.qr'}-@:{'words.generated.kod'}" "unknown_qr_title": "@:{'words.generated.okand'} @:{'words.generated.qr'}-@:{'words.generated.kod'}"
}, },
"maintenance_menu": {
"confirm_clear_local": "Rensa all lokal appdata och ladda om nu?",
"force_update_clear_hint": "Rensar local storage, session storage, service workers och webbläsarcacher.",
"force_update_clear": "Tvinga uppdatering och rensa allt lokalt",
"title": "Underhåll",
"version": "Aktuell {current} / senaste {latest}"
},
"messages": { "messages": {
"are_you_sure": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'}?", "are_you_sure": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'}?",
"cannot_undo": "@.capitalize:{'words.generated.denna'} @:{'words.generated.atgard'} @:{'words.generated.kan'} @:{'words.generated.inte'} @:{'words.generated.angras'}.", "cannot_undo": "@.capitalize:{'words.generated.denna'} @:{'words.generated.atgard'} @:{'words.generated.kan'} @:{'words.generated.inte'} @:{'words.generated.angras'}.",
@@ -5670,9 +5639,7 @@
"email_notifications_desc": "@.capitalize:{'words.generated.ta'} @:{'words.generated.emot'} @:{'words.generated.e'}-@:{'words.generated.post'} @:{'words.generated.med'} @:{'words.generated.bokningsbekraftelser'} @:{'words.generated.och'} @:{'words.generated.tvattcertifikat'}.", "email_notifications_desc": "@.capitalize:{'words.generated.ta'} @:{'words.generated.emot'} @:{'words.generated.e'}-@:{'words.generated.post'} @:{'words.generated.med'} @:{'words.generated.bokningsbekraftelser'} @:{'words.generated.och'} @:{'words.generated.tvattcertifikat'}.",
"sms_notifications_desc": "@.capitalize:{'words.generated.ta'} @:{'words.generated.emot'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'} @:{'words.generated.for'} @:{'words.generated.olika'} @:{'words.generated.handelser'}.", "sms_notifications_desc": "@.capitalize:{'words.generated.ta'} @:{'words.generated.emot'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'} @:{'words.generated.for'} @:{'words.generated.olika'} @:{'words.generated.handelser'}.",
"sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}", "sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}",
"sms_phone_desc": "@.capitalize:{'words.generated.telefonnumret'} @:{'words.generated.som'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}.", "sms_phone_desc": "@.capitalize:{'words.generated.telefonnumret'} @:{'words.generated.som'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}."
"superuser_notifications": "Superanvändaraviseringar",
"superuser_notifications_desc": "Ta emot e-postaviseringar när en ny kund registrerar sig på sidan."
}, },
"security": { "security": {
"description": "@.capitalize:{'words.generated.dina'} säkerhetsinställningar", "description": "@.capitalize:{'words.generated.dina'} säkerhetsinställningar",
+83 -88
View File
@@ -342,7 +342,7 @@
"attachments": "Vedhæftninger", "attachments": "Vedhæftninger",
"attachments_add": "Tilføj vedhæftning", "attachments_add": "Tilføj vedhæftning",
"attachments_no_preview": "Ingen forhåndsvisning tilgængelig for denne filtype.", "attachments_no_preview": "Ingen forhåndsvisning tilgængelig for denne filtype.",
"attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgængelig for Office-dokumenter. Download filen for at se den.", "attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgjengelig for Office-dokumenter. Last ned for å se.",
"attachments_upload_description": "Upload en ny fil som vedhæftning til denne transaktion.", "attachments_upload_description": "Upload en ny fil som vedhæftning til denne transaktion.",
"attachments_upload_action": "Upload", "attachments_upload_action": "Upload",
"attachments_upload_error": "Fejl ved upload af vedhæftet fil.", "attachments_upload_error": "Fejl ved upload af vedhæftet fil.",
@@ -369,7 +369,7 @@
"customer_creation_error_generic": "Der opstod en fejl under oprettelse af kunden. Prøv igen.", "customer_creation_error_generic": "Der opstod en fejl under oprettelse af kunden. Prøv igen.",
"delete": "Slet", "delete": "Slet",
"details": "Detaljer", "details": "Detaljer",
"download": "Download", "download": "Last ned",
"duplicate_order_warning": "Er du sikker på, at dette ikke allerede er oprettet? Mulige dubletter af denne ordre er fundet. Du kan fortsætte med at oprette ordren eller se ordredetaljer.", "duplicate_order_warning": "Er du sikker på, at dette ikke allerede er oprettet? Mulige dubletter af denne ordre er fundet. Du kan fortsætte med at oprette ordren eller se ordredetaljer.",
"hide_order_details": "Skjul ordredetaljer", "hide_order_details": "Skjul ordredetaljer",
"duplicates": { "duplicates": {
@@ -572,7 +572,7 @@
"reference_required_title": "Referencenummer påkrævet", "reference_required_title": "Referencenummer påkrævet",
"safety_seal": "Sikkerhedsforsegling", "safety_seal": "Sikkerhedsforsegling",
"scanned": "Scannet", "scanned": "Scannet",
"scanner": "Scanner", "scanner": "Skanner",
"search_license_plate": "Søg efter nummerplade", "search_license_plate": "Søg efter nummerplade",
"see_note": "Se note", "see_note": "Se note",
"see_reference": "Se reference", "see_reference": "Se reference",
@@ -859,8 +859,24 @@
}, },
"day": "Dag", "day": "Dag",
"dayHeaderFormat": "ddd D/M", "dayHeaderFormat": "ddd D/M",
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"], "dayNames": [
"dayNamesShort": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"], "Søndag",
"Mandag",
"Tirsdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lørdag"
],
"dayNamesShort": [
"Søn",
"Man",
"Tir",
"Ons",
"Tor",
"Fre",
"Lør"
],
"eventTimeFormat": "HH:mm", "eventTimeFormat": "HH:mm",
"list": "Liste", "list": "Liste",
"month": "Måned", "month": "Måned",
@@ -878,7 +894,20 @@
"November", "November",
"Desember" "Desember"
], ],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"], "monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Maj",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"next": "I denne", "next": "I denne",
"prev": "Forrige", "prev": "Forrige",
"slotLabelFormat": "HH:mm", "slotLabelFormat": "HH:mm",
@@ -907,21 +936,6 @@
"year": "År" "year": "År"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Flyt til kunde",
"confirm_button": "Flyt",
"confirm_text": "Flyt fakturasamling #{id} og {count} ordre til kunde #{customer}?",
"confirm_title": "Bekræft flytning",
"current_customer": "Nuværende kunde: #{customer}",
"customer_value": "Kunde #{customer}",
"error_title": "Kunne ikke flytte fakturasamling",
"invalid_customer": "Indtast et gyldigt kundenummer.",
"locked": "Låst",
"same_customer": "Vælg en anden kunde end den nuværende.",
"success_text": "{count} ordre blev flyttet til kunde #{customer}.",
"success_title": "Fakturasamling flyttet",
"title": "Flyt fakturasamling"
},
"economic": { "economic": {
"booked_desc": "Fakturaen er bogført i E-conomic, ingen yderligere handlinger er nødvendige.", "booked_desc": "Fakturaen er bogført i E-conomic, ingen yderligere handlinger er nødvendige.",
"booked_with_economic": "Fakturaen er bogført med E-conomic", "booked_with_economic": "Fakturaen er bogført med E-conomic",
@@ -1250,7 +1264,7 @@
"reply_to_name_desc": "Navnet som emailen besvares til. F.eks. Support", "reply_to_name_desc": "Navnet som emailen besvares til. F.eks. Support",
"send": "Send", "send": "Send",
"send_test_email": "Send test email", "send_test_email": "Send test email",
"send_test_email_prompt": "Indtast den e-mailadresse, du vil sende test-e-mailen til.", "send_test_email_prompt": "Indtast venligst email adressen du vil sende test emailen til.",
"sender_identity": "Afsender identitet", "sender_identity": "Afsender identitet",
"sender_identity_desc": "Afsender identitet indstillinger for email systemet.", "sender_identity_desc": "Afsender identitet indstillinger for email systemet.",
"smtp_encryption": "SMTP kryptering", "smtp_encryption": "SMTP kryptering",
@@ -1268,13 +1282,13 @@
"smtp_username_desc": "SMTP brugernavnet er brugernavnet som SMTP hosten bruger til at sende emailen.", "smtp_username_desc": "SMTP brugernavnet er brugernavnet som SMTP hosten bruger til at sende emailen.",
"subtitle": "Konfiguration af email systemet.", "subtitle": "Konfiguration af email systemet.",
"test_email_error": "Der opstod en fejl under afsendelse af test emailen.", "test_email_error": "Der opstod en fejl under afsendelse af test emailen.",
"test_email_sent": "Test-e-mail sendt", "test_email_sent": "Test email sendt",
"test_email_sent_success": "Test-e-mailen er blevet sendt til e-mailadressen.", "test_email_sent_success": "Test emailen er blevet sendt til email adressen.",
"title": "Email konfiguration" "title": "Email konfiguration"
}, },
"fxrates": { "fxrates": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "API-nøglen til valutakurser bruges til at hente valutakurser", "api_key_desc": "Valutakurser API-nøkkelen bruges til å hente ut valutakurser",
"api_settings": "API forbindelsesindstillinger", "api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "FX Rates API forbindelsesindstillingerne.", "api_settings_desc": "FX Rates API forbindelsesindstillingerne.",
"conversion_rates": "Valutakurser", "conversion_rates": "Valutakurser",
@@ -1298,7 +1312,7 @@
}, },
"general": { "general": {
"currency": "Valuta", "currency": "Valuta",
"language": "Sprog", "language": "Språk",
"subtitle": "Generelle indstillinger", "subtitle": "Generelle indstillinger",
"timezone": "Tidszone", "timezone": "Tidszone",
"title": "Generelle indstillinger" "title": "Generelle indstillinger"
@@ -1308,13 +1322,13 @@
"title": "Integrationer" "title": "Integrationer"
}, },
"limble": { "limble": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "Limble API-nøglen bruges til at autentificere Limble-integrationen", "api_key_desc": "Limble API-nøkkelen bruges til å autentisere Limble-integrationen",
"api_settings": "API forbindelsesindstillinger", "api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Limble API forbindelsesindstillingerne.", "api_settings_desc": "Limble API forbindelsesindstillingerne.",
"client_id": "Klient-ID", "client_id": "Klient-ID",
"client_id_desc": "Client ID bruges til at autentificere API forbindelsen.", "client_id_desc": "Client ID bruges til at autentificere API forbindelsen.",
"client_secret": "Klienthemmelighed", "client_secret": "Klienthemmelighet",
"client_secret_desc": "Client secret bruges til at autentificere API forbindelsen.", "client_secret_desc": "Client secret bruges til at autentificere API forbindelsen.",
"connection_failed_desc": "Forbindelsen til Limble API fejlede!", "connection_failed_desc": "Forbindelsen til Limble API fejlede!",
"connection_success": "Forbindelse succesfuld", "connection_success": "Forbindelse succesfuld",
@@ -1330,13 +1344,13 @@
"title": "Limble konfiguration" "title": "Limble konfiguration"
}, },
"mailersend": { "mailersend": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen", "api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
"title": "Mailersend-konfiguration" "title": "Mailersend-konfiguration"
}, },
"motor_api": { "motor_api": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "Motor API-nøglen bruges til at autentificere Motor API-integrationen", "api_key_desc": "Motor API-nøkkelen bruges til å autentisere Motor API-integrationen",
"title": "Motor API-konfiguration" "title": "Motor API-konfiguration"
}, },
"motorapi": { "motorapi": {
@@ -1355,7 +1369,7 @@
"title": "MotorAPI konfiguration" "title": "MotorAPI konfiguration"
}, },
"products": { "products": {
"add": "Tilføj produkt", "add": "Legg til produkt",
"delete": "Slet produkt", "delete": "Slet produkt",
"delete_confirm": "Er du sikker på, at du vil slette dette produkt?", "delete_confirm": "Er du sikker på, at du vil slette dette produkt?",
"edit": "Rediger produkt", "edit": "Rediger produkt",
@@ -1363,18 +1377,9 @@
"subtitle": "Administrer produkter", "subtitle": "Administrer produkter",
"title": "Produkter" "title": "Produkter"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL til kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
"notification_settings": "Notifikationer",
"notification_settings_desc": "Slack-webhooks til systemhændelser.",
"subtitle": "Konfiguration af Slack-notifikationer",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
"shelly": { "shelly": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "Shelly API-nøglen bruges til at autentificere Shelly-integrationen", "api_key_desc": "Shelly API-nøkkelen bruges til å autentisere Shelly-integrationen",
"api_settings": "API forbindelsesindstillinger", "api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Shelly API forbindelsesindstillingerne.", "api_settings_desc": "Shelly API forbindelsesindstillingerne.",
"connection_failed_desc": "Forbindelsen til Shelly API fejlede!", "connection_failed_desc": "Forbindelsen til Shelly API fejlede!",
@@ -1393,8 +1398,8 @@
"title": "Shelly konfiguration" "title": "Shelly konfiguration"
}, },
"stripe": { "stripe": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "Din Stripe API-nøgle for betalingsbehandling", "api_key_desc": "Din Stripe API-nøkkel for betalingsbehandling",
"api_settings": "API forbindelsesindstillinger", "api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Stripe API forbindelsesindstillingerne.", "api_settings_desc": "Stripe API forbindelsesindstillingerne.",
"customers": "Stripe kunder", "customers": "Stripe kunder",
@@ -1429,8 +1434,8 @@
"title": "Brugere" "title": "Brugere"
}, },
"virkdata": { "virkdata": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "Virk Data API-nøglen bruges til at autentificere Virk Data-integrationen", "api_key_desc": "Virk Data API-nøkkelen bruges til å autentisere Virk Data-integrationen",
"api_settings": "API forbindelsesindstillinger", "api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "VirkData API forbindelsesindstillingerne.", "api_settings_desc": "VirkData API forbindelsesindstillingerne.",
"company_selected": "Virksomhed valgt", "company_selected": "Virksomhed valgt",
@@ -1453,8 +1458,8 @@
"title": "VirkData konfiguration" "title": "VirkData konfiguration"
}, },
"xlvask": { "xlvask": {
"api_key": "API-Nøgle", "api_key": "API-nøkkel",
"api_key_desc": "XL Vask API-nøglen bruges til at autentificere XL Vask-integrationen", "api_key_desc": "XL Vask API-nøkkelen bruges til å autentisere XL Vask-integrationen",
"api_settings": "API forbindelsesindstillinger", "api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "XLVask API forbindelsesindstillingerne.", "api_settings_desc": "XLVask API forbindelsesindstillingerne.",
"connection_failed": "Forbindelse fejlede", "connection_failed": "Forbindelse fejlede",
@@ -2208,7 +2213,7 @@
"list": "Liste", "list": "Liste",
"subtitle": "Se hvordan dine afdelinger klarer sig", "subtitle": "Se hvordan dine afdelinger klarer sig",
"title": "Overblik", "title": "Overblik",
"charts": "Grafer" "charts": "Charts"
}, },
"pos": { "pos": {
"subtitle": "Registrer salg", "subtitle": "Registrer salg",
@@ -2307,15 +2312,15 @@
"country": "Land", "country": "Land",
"email": "E-mail", "email": "E-mail",
"latitude": "Breddegrad", "latitude": "Breddegrad",
"longitude": "Længdegrad", "longitude": "Lengdegrad",
"name": "Navn", "name": "Navn",
"phone": "Telefon", "phone": "Telefon",
"submit": "Gem", "submit": "Lagre",
"zip": "Postnummer" "zip": "Postnummer"
}, },
"integrations": { "integrations": {
"mailersend_api_key": "Mailersend API-Nøgle", "mailersend_api_key": "Mailersend API-nøkkel",
"mailersend_api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen", "mailersend_api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
"subtitle": "Konfigurer integrationer for afdelingen", "subtitle": "Konfigurer integrationer for afdelingen",
"title": "Integrationer" "title": "Integrationer"
}, },
@@ -2323,9 +2328,9 @@
"new_department": "Ny afdeling", "new_department": "Ny afdeling",
"no_departments": "Ingen afdelinger fundet", "no_departments": "Ingen afdelinger fundet",
"opening_hours": { "opening_hours": {
"add": "Tilføj", "add": "Legg til",
"closed": "Lukket", "closed": "Stengt",
"title": bningstider" "title": pningstider"
}, },
"phone": "Telefon", "phone": "Telefon",
"products": { "products": {
@@ -2337,7 +2342,7 @@
"tab": { "tab": {
"info": "Info", "info": "Info",
"integrations": "Integrationer", "integrations": "Integrationer",
"opening_hours": bningstider", "opening_hours": pningstider",
"product_addons": "Produkttilvalg", "product_addons": "Produkttilvalg",
"wash_products": "Vaskeprodukter" "wash_products": "Vaskeprodukter"
}, },
@@ -2358,7 +2363,7 @@
"name": "Navn", "name": "Navn",
"phone": "Telefon", "phone": "Telefon",
"role": "Rolle", "role": "Rolle",
"submit": "Gem" "submit": "Lagre"
}, },
"name": "Navn", "name": "Navn",
"new_employee": "Ny medarbejder", "new_employee": "Ny medarbejder",
@@ -2652,17 +2657,17 @@
"yes": "Ja" "yes": "Ja"
}, },
"guest": { "guest": {
"about": "Om os", "about": "Om oss",
"access_code": "Adgangskode", "access_code": "Tilgangskode",
"app_store": "App Store", "app_store": "App Store",
"book_wash": "Book vask", "book_wash": "Book vask",
"contact": "Kontakt os", "contact": "Kontakta oss",
"continue": "Fortsæt", "continue": "Fortsett",
"download_app": "Download app", "download_app": "Ladda ner app",
"driver": { "driver": {
"coming_soon": "Denne funktion er på vej!" "coming_soon": "Denne funktion er på vej!"
}, },
"enter_access_code": "Indtast adgangskode", "enter_access_code": "Skriv inn tilgangskode",
"find_wash": "Find vask", "find_wash": "Find vask",
"home": { "home": {
"contact_or_call": "eller ring til os på", "contact_or_call": "eller ring til os på",
@@ -2671,12 +2676,12 @@
"subtitle": "Velkommen til PLENO", "subtitle": "Velkommen til PLENO",
"title": "Forside" "title": "Forside"
}, },
"invalid_code": "Ugyldig adgangskode", "invalid_code": "Ugyldig tilgangskode",
"play_store": "Google Play", "play_store": "Google Play",
"pwa": { "pwa": {
"already_installed": "Pleno er allerede installeret på din enhed.", "already_installed": "Pleno er allerede installeret på din enhed.",
"android_instructions": "Tryk på menuknappen og vælg \"Installer app\"", "android_instructions": "Tryk på menuknappen og vælg \"Installer app\"",
"card_title": "Download Pleno", "card_title": "Last ned Pleno",
"click_to_install": "Klik på knappen nedenfor for at installere Pleno på din enhed.", "click_to_install": "Klik på knappen nedenfor for at installere Pleno på din enhed.",
"description": "Pleno er en Progressive Web App (PWA), der kan installeres på din enhed for en bedre oplevelse.", "description": "Pleno er en Progressive Web App (PWA), der kan installeres på din enhed for en bedre oplevelse.",
"install": "Installer", "install": "Installer",
@@ -2686,14 +2691,14 @@
"instructions": "Instruktioner", "instructions": "Instruktioner",
"ios_instructions": "Tryk på delingsknappen og vælg \"Tilføj til hjemmeskærm\"", "ios_instructions": "Tryk på delingsknappen og vælg \"Tilføj til hjemmeskærm\"",
"subtitle": "Download appen på din enhed", "subtitle": "Download appen på din enhed",
"title": "Download", "title": "Last ned",
"use_supported_browser": "For at installere Pleno skal du bruge en understøttet browser og følge installationsvejledningen." "use_supported_browser": "For at installere Pleno skal du bruge en understøttet browser og følge installationsvejledningen."
}, },
"scan_qr": "Scan QR-kode", "scan_qr": "Skann QR-kode",
"start_wash": "Start vask", "start_wash": "Start vask",
"start_wash_subtitle": "Indtast adgangskoden for at fortsætte", "start_wash_subtitle": "Skriv inn tilgangskoden for å fortsette",
"start_wash_title": "Start vask som gæst", "start_wash_title": "Start vask som gjest",
"title": "Gæsteadgang", "title": "Gjesttilgang",
"welcome": "Velkommen", "welcome": "Velkommen",
"welcome_message": "Velkommen til Truck Wash" "welcome_message": "Velkommen til Truck Wash"
}, },
@@ -2790,13 +2795,6 @@
"unknown_qr_text": "Den scannede QR kode er ikke gyldig for denne side.", "unknown_qr_text": "Den scannede QR kode er ikke gyldig for denne side.",
"unknown_qr_title": "Ukendt QR kode" "unknown_qr_title": "Ukendt QR kode"
}, },
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlæs nu?",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
"version": "Nuværende {current} / seneste {latest}"
},
"messages": { "messages": {
"are_you_sure": "Er du sikker?", "are_you_sure": "Er du sikker?",
"cancelled": "Annulleret", "cancelled": "Annulleret",
@@ -3147,7 +3145,7 @@
"invoices": { "invoices": {
"columns": { "columns": {
"amount": "Beløb", "amount": "Beløb",
"download": "Download", "download": "Last ned",
"due_date": "Forfaldsdato", "due_date": "Forfaldsdato",
"invoice_number": "Fakturanummer", "invoice_number": "Fakturanummer",
"paid": "Betalt" "paid": "Betalt"
@@ -3760,7 +3758,7 @@
"days": "dage", "days": "dage",
"export": "Eksporter", "export": "Eksporter",
"growth": "Vækst", "growth": "Vækst",
"no_data": "Ingen data tilgængelige", "no_data": "Ingen data tilgjengelig",
"none": "Ingen", "none": "Ingen",
"orders": "Ordrer", "orders": "Ordrer",
"overview": { "overview": {
@@ -3864,7 +3862,6 @@
"xlvask": "XLVask", "xlvask": "XLVask",
"department_gates": "Afdelingsporte", "department_gates": "Afdelingsporte",
"department_relays": "Afdelingsreleer", "department_relays": "Afdelingsreleer",
"error_reports": "Fejlrapporter",
"selfserve": "Selvvask" "selfserve": "Selvvask"
}, },
"pages": { "pages": {
@@ -4658,8 +4655,6 @@
"sms_phone": "Telefonnummer til SMS notifikationer", "sms_phone": "Telefonnummer til SMS notifikationer",
"sms_phone_desc": "Det telefonnummer, der modtager SMS notifikationer.", "sms_phone_desc": "Det telefonnummer, der modtager SMS notifikationer.",
"sms_subtitle": "SMS", "sms_subtitle": "SMS",
"superuser_notifications": "Superuser-notifikationer",
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden.",
"title": "Notifikationer" "title": "Notifikationer"
}, },
"security": { "security": {
@@ -5196,7 +5191,7 @@
"fixed_pricing": "Fastprisaftaler", "fixed_pricing": "Fastprisaftaler",
"subscriptions": "Abonnementer", "subscriptions": "Abonnementer",
"customer_prices": "Kundepriser", "customer_prices": "Kundepriser",
"other": "Anden bogført indtægt", "other": "Annen bokført inntekt",
"months": "Måneder i visning" "months": "Måneder i visning"
}, },
"tabs": { "tabs": {
+33 -38
View File
@@ -859,8 +859,24 @@
}, },
"day": "Tag", "day": "Tag",
"dayHeaderFormat": "ddd D/M", "dayHeaderFormat": "ddd D/M",
"dayNames": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"], "dayNames": [
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"], "Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag"
],
"dayNamesShort": [
"Søn",
"Man",
"Tir",
"Mi",
"Do",
"Fre",
"Lør"
],
"eventTimeFormat": "HH:mm", "eventTimeFormat": "HH:mm",
"list": "Liste", "list": "Liste",
"month": "Monat", "month": "Monat",
@@ -878,7 +894,20 @@
"November", "November",
"Desember" "Desember"
], ],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"], "monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"next": "Neste", "next": "Neste",
"prev": "Vorherige", "prev": "Vorherige",
"slotLabelFormat": "HH:mm", "slotLabelFormat": "HH:mm",
@@ -907,21 +936,6 @@
"year": "Jahr" "year": "Jahr"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "Die Rechnung ist in E-conomic gebucht, keine weiteren Aktionen sind erforderlich.", "booked_desc": "Die Rechnung ist in E-conomic gebucht, keine weiteren Aktionen sind erforderlich.",
"booked_with_economic": "Rechnung mit E-conomic gebucht", "booked_with_economic": "Rechnung mit E-conomic gebucht",
@@ -1363,15 +1377,6 @@
"subtitle": "Produkte verwalten", "subtitle": "Produkte verwalten",
"title": "Produkte" "title": "Produkte"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL fuer Kundenregistrierungen",
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
"notification_settings": "Benachrichtigungseinstellungen",
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
"title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
"shelly": { "shelly": {
"api_key": "API-Schl?ssel", "api_key": "API-Schl?ssel",
"api_key_desc": "Der Shelly API-Schl?ssel wird zur Authentifizierung der Shelly-Integration verwendet", "api_key_desc": "Der Shelly API-Schl?ssel wird zur Authentifizierung der Shelly-Integration verwendet",
@@ -2790,13 +2795,6 @@
"unknown_qr_text": "Der gescannte QR-Code ist f?r diese Seite nicht g?ltig.", "unknown_qr_text": "Der gescannte QR-Code ist f?r diese Seite nicht g?ltig.",
"unknown_qr_title": "Unbekannter QR-Code" "unknown_qr_title": "Unbekannter QR-Code"
}, },
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
"version": "Aktuell {current} / neueste {latest}"
},
"messages": { "messages": {
"are_you_sure": "Sind Sie sicher?", "are_you_sure": "Sind Sie sicher?",
"cancelled": "Erfolgreich storniert", "cancelled": "Erfolgreich storniert",
@@ -3862,8 +3860,7 @@
"vehicles": "Fahrzeuge", "vehicles": "Fahrzeuge",
"xlvask": "XLVask", "xlvask": "XLVask",
"department_gates": "Abteilungstore", "department_gates": "Abteilungstore",
"department_relays": "Abteilungsrelais", "department_relays": "Abteilungsrelais"
"error_reports": "Fehlerberichte"
}, },
"pages": { "pages": {
"categories": { "categories": {
@@ -4644,8 +4641,6 @@
"sms_phone": "Telefonnummer f?r SMS-Benachrichtigungen", "sms_phone": "Telefonnummer f?r SMS-Benachrichtigungen",
"sms_phone_desc": "Die Telefonnummer, die SMS-Benachrichtigungen erh?lt.", "sms_phone_desc": "Die Telefonnummer, die SMS-Benachrichtigungen erh?lt.",
"sms_subtitle": "SMS", "sms_subtitle": "SMS",
"superuser_notifications": "Superuser-Benachrichtigungen",
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert.",
"title": "Benachrichtigungen" "title": "Benachrichtigungen"
}, },
"security": { "security": {
+33 -38
View File
@@ -859,8 +859,24 @@
}, },
"day": "Day", "day": "Day",
"dayHeaderFormat": "ddd D/M", "dayHeaderFormat": "ddd D/M",
"dayNames": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], "dayNames": [
"dayNamesShort": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], "Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
"dayNamesShort": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"eventTimeFormat": "HH:mm", "eventTimeFormat": "HH:mm",
"list": "List", "list": "List",
"month": "Month", "month": "Month",
@@ -878,7 +894,20 @@
"November", "November",
"December" "December"
], ],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], "monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"next": "Next", "next": "Next",
"prev": "Previous", "prev": "Previous",
"slotLabelFormat": "HH:mm", "slotLabelFormat": "HH:mm",
@@ -907,21 +936,6 @@
"year": "Year" "year": "Year"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "The invoice is booked in E-conomic, no further actions are required.", "booked_desc": "The invoice is booked in E-conomic, no further actions are required.",
"booked_with_economic": "Invoice booked with E-conomic", "booked_with_economic": "Invoice booked with E-conomic",
@@ -1363,15 +1377,6 @@
"subtitle": "Manage products", "subtitle": "Manage products",
"title": "Products" "title": "Products"
}, },
"slack": {
"customer_registration_webhook_url": "Customer registration webhook URL",
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
"notification_settings": "Notification settings",
"notification_settings_desc": "Slack webhooks for system events.",
"subtitle": "Configuration of Slack notifications",
"title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release."
},
"shelly": { "shelly": {
"api_key": "API-nyckel", "api_key": "API-nyckel",
"api_key_desc": "The Shelly API key is used to authenticate the Shelly integration", "api_key_desc": "The Shelly API key is used to authenticate the Shelly integration",
@@ -2790,13 +2795,6 @@
"unknown_qr_text": "The scanned QR code is not valid for this page.", "unknown_qr_text": "The scanned QR code is not valid for this page.",
"unknown_qr_title": "Unknown QR Code" "unknown_qr_title": "Unknown QR Code"
}, },
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
"version": "Current {current} / latest {latest}"
},
"messages": { "messages": {
"are_you_sure": "Are you sure?", "are_you_sure": "Are you sure?",
"cancelled": "Canceled successfully", "cancelled": "Canceled successfully",
@@ -3862,8 +3860,7 @@
"vehicles": "Vehicles", "vehicles": "Vehicles",
"xlvask": "XLVask", "xlvask": "XLVask",
"department_gates": "Department Gates", "department_gates": "Department Gates",
"department_relays": "Department Relays", "department_relays": "Department Relays"
"error_reports": "Error reports"
}, },
"pages": { "pages": {
"categories": { "categories": {
@@ -4656,8 +4653,6 @@
"sms_phone": "Phone number for SMS notifications", "sms_phone": "Phone number for SMS notifications",
"sms_phone_desc": "The phone number that receives SMS notifications.", "sms_phone_desc": "The phone number that receives SMS notifications.",
"sms_subtitle": "SMS", "sms_subtitle": "SMS",
"superuser_notifications": "Superuser notifications",
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site.",
"title": "Notifications" "title": "Notifications"
}, },
"security": { "security": {
+33 -38
View File
@@ -859,8 +859,24 @@
}, },
"day": "Dag", "day": "Dag",
"dayHeaderFormat": "ddd D/M", "dayHeaderFormat": "ddd D/M",
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"], "dayNames": [
"dayNamesShort": ["Søn", "Mann", "Tir", "Ons", "Tor", "Fre", "Lør"], "Søndag",
"Mandag",
"Tirsdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lørdag"
],
"dayNamesShort": [
"Søn",
"Mann",
"Tir",
"Ons",
"Tor",
"Fre",
"Lør"
],
"eventTimeFormat": "HH:mm", "eventTimeFormat": "HH:mm",
"list": "Liste", "list": "Liste",
"month": "Måned", "month": "Måned",
@@ -878,7 +894,20 @@
"november", "november",
"desember" "desember"
], ],
"monthNamesShort": ["Jan", "feb", "Mar", "apr", "mai", "jun", "jul", "august", "sep", "Okt", "nov", "Av"], "monthNamesShort": [
"Jan",
"feb",
"Mar",
"apr",
"mai",
"jun",
"jul",
"august",
"sep",
"Okt",
"nov",
"Av"
],
"next": "I dette", "next": "I dette",
"prev": "Forrige", "prev": "Forrige",
"slotLabelFormat": "HH:mm", "slotLabelFormat": "HH:mm",
@@ -907,21 +936,6 @@
"year": "År" "year": "År"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "Fakturaen er bokført i E-conomic, ingen ytterligere handlinger er nødvendig.", "booked_desc": "Fakturaen er bokført i E-conomic, ingen ytterligere handlinger er nødvendig.",
"booked_with_economic": "Faktura bestilles hos E-conomic", "booked_with_economic": "Faktura bestilles hos E-conomic",
@@ -1363,15 +1377,6 @@
"subtitle": "Administrer produkter", "subtitle": "Administrer produkter",
"title": "Produkter" "title": "Produkter"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL for kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"subtitle": "Konfigurasjon av Slack-varsler",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
"shelly": { "shelly": {
"api_key": "API-nøkkel", "api_key": "API-nøkkel",
"api_key_desc": "Shelly API-nøkkelen brukes til å autentisere Shelly-integrasjonen", "api_key_desc": "Shelly API-nøkkelen brukes til å autentisere Shelly-integrasjonen",
@@ -2790,13 +2795,6 @@
"unknown_qr_text": "Den skannede QR-koden er ikke gyldig for denne siden.", "unknown_qr_text": "Den skannede QR-koden er ikke gyldig for denne siden.",
"unknown_qr_title": "Ukjent QR-kode" "unknown_qr_title": "Ukjent QR-kode"
}, },
"maintenance_menu": {
"confirm_clear_local": "Fjern alle lokale appdata og last inn på nytt nå?",
"force_update_clear_hint": "Fjerner local storage, session storage, service workers og nettlesercacher.",
"force_update_clear": "Tving oppdatering og fjern alt lokalt",
"title": "Vedlikehold",
"version": "Gjeldende {current} / nyeste {latest}"
},
"messages": { "messages": {
"are_you_sure": "er du sikker?", "are_you_sure": "er du sikker?",
"cancelled": "Avbrutt", "cancelled": "Avbrutt",
@@ -3862,8 +3860,7 @@
"vehicles": "Kjøretøy", "vehicles": "Kjøretøy",
"xlvask": "XLVask", "xlvask": "XLVask",
"department_gates": "Avdelingsporter", "department_gates": "Avdelingsporter",
"department_relays": "Avdelingsreleer", "department_relays": "Avdelingsreleer"
"error_reports": "Feilrapporter"
}, },
"pages": { "pages": {
"categories": { "categories": {
@@ -4644,8 +4641,6 @@
"sms_phone": "Telefonnummer for SMS-varsler", "sms_phone": "Telefonnummer for SMS-varsler",
"sms_phone_desc": "Telefonnummeret som mottar SMS-varsler.", "sms_phone_desc": "Telefonnummeret som mottar SMS-varsler.",
"sms_subtitle": "SMS", "sms_subtitle": "SMS",
"superuser_notifications": "Superbrukervarsler",
"superuser_notifications_desc": "Motta e-postvarsler når en ny kunde registrerer seg på siden.",
"title": "Varsler" "title": "Varsler"
}, },
"security": { "security": {
+33 -38
View File
@@ -859,8 +859,24 @@
}, },
"day": "Dag", "day": "Dag",
"dayHeaderFormat": "ddd D/M", "dayHeaderFormat": "ddd D/M",
"dayNames": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"], "dayNames": [
"dayNamesShort": ["Sön", "Man", "Tir", "Ons", "Tor", "Fre", "Lör"], "Söndag",
"Måndag",
"Tisdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lördag"
],
"dayNamesShort": [
"Sön",
"Man",
"Tir",
"Ons",
"Tor",
"Fre",
"Lör"
],
"eventTimeFormat": "HH:mm", "eventTimeFormat": "HH:mm",
"list": "Liste", "list": "Liste",
"month": "Månad", "month": "Månad",
@@ -878,7 +894,20 @@
"November", "November",
"Desember" "Desember"
], ],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"], "monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"next": "Neste", "next": "Neste",
"prev": "Föregående", "prev": "Föregående",
"slotLabelFormat": "HH:mm", "slotLabelFormat": "HH:mm",
@@ -907,21 +936,6 @@
"year": "År" "year": "År"
}, },
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "Fakturan är bokförd i E-conomic, inga ytterligare åtgärder krävs.", "booked_desc": "Fakturan är bokförd i E-conomic, inga ytterligare åtgärder krävs.",
"booked_with_economic": "Faktura bokförd i E-conomic", "booked_with_economic": "Faktura bokförd i E-conomic",
@@ -1363,15 +1377,6 @@
"subtitle": "Administrer produkter", "subtitle": "Administrer produkter",
"title": "Produkter" "title": "Produkter"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL för kundregistreringar",
"customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.",
"notification_settings": "Aviseringsinställningar",
"notification_settings_desc": "Slack-webhooks för systemhändelser.",
"subtitle": "Konfiguration av Slack-aviseringar",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
},
"shelly": { "shelly": {
"api_key": "API-nyckel", "api_key": "API-nyckel",
"api_key_desc": "Shelly API-nyckeln används för att autentisera Shelly-integrationen", "api_key_desc": "Shelly API-nyckeln används för att autentisera Shelly-integrationen",
@@ -2790,13 +2795,6 @@
"unknown_qr_text": "The scanned QR code is not valid for this page.", "unknown_qr_text": "The scanned QR code is not valid for this page.",
"unknown_qr_title": "Okänd QR-kod" "unknown_qr_title": "Okänd QR-kod"
}, },
"maintenance_menu": {
"confirm_clear_local": "Rensa all lokal appdata och ladda om nu?",
"force_update_clear_hint": "Rensar local storage, session storage, service workers och webbläsarcacher.",
"force_update_clear": "Tvinga uppdatering och rensa allt lokalt",
"title": "Underhåll",
"version": "Aktuell {current} / senaste {latest}"
},
"messages": { "messages": {
"are_you_sure": "Är du säker?", "are_you_sure": "Är du säker?",
"cancelled": "Avbruten framgångsrikt", "cancelled": "Avbruten framgångsrikt",
@@ -3862,8 +3860,7 @@
"vehicles": "Fordon", "vehicles": "Fordon",
"xlvask": "XLVask", "xlvask": "XLVask",
"department_gates": "Avdelningsgrindar", "department_gates": "Avdelningsgrindar",
"department_relays": "Avdelningsreläer", "department_relays": "Avdelningsreläer"
"error_reports": "Felrapporter"
}, },
"pages": { "pages": {
"categories": { "categories": {
@@ -4644,8 +4641,6 @@
"sms_phone": "Telefonnummer för SMS-aviseringar", "sms_phone": "Telefonnummer för SMS-aviseringar",
"sms_phone_desc": "Telefonnumret som för SMS-aviseringar.", "sms_phone_desc": "Telefonnumret som för SMS-aviseringar.",
"sms_subtitle": "SMS", "sms_subtitle": "SMS",
"superuser_notifications": "Superanvändaraviseringar",
"superuser_notifications_desc": "Ta emot e-postaviseringar när en ny kund registrerar sig på sidan.",
"title": "Aviseringar" "title": "Aviseringar"
}, },
"security": { "security": {
@@ -1,21 +1,6 @@
{ {
"compat": { "compat": {
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Flyt til kunde",
"confirm_button": "Flyt",
"confirm_text": "Flyt fakturasamling #{id} og {count} ordre til kunde #{customer}?",
"confirm_title": "Bekræft flytning",
"current_customer": "Nuværende kunde: #{customer}",
"customer_value": "Kunde #{customer}",
"error_title": "Kunne ikke flytte fakturasamling",
"invalid_customer": "Indtast et gyldigt kundenummer.",
"locked": "Låst",
"same_customer": "Vælg en anden kunde end den nuværende.",
"success_text": "{count} ordre blev flyttet til kunde #{customer}.",
"success_title": "Fakturasamling flyttet",
"title": "Flyt fakturasamling"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bogført'} @:{'terms.glossary.i'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.ingen'} @:{'terms.glossary.yderligere'} @:{'terms.glossary.handlinger'} @:{'terms.glossary.er'} @:{'terms.glossary.nødvendige'}.", "booked_desc": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bogført'} @:{'terms.glossary.i'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.ingen'} @:{'terms.glossary.yderligere'} @:{'terms.glossary.handlinger'} @:{'terms.glossary.er'} @:{'terms.glossary.nødvendige'}.",
"booked_with_economic": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bogført'} @:{'terms.glossary.med'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}", "booked_with_economic": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bogført'} @:{'terms.glossary.med'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}",
@@ -158,15 +158,6 @@
"delete_confirm": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.sikker'} @:{'terms.glossary.pa'}, @:{'terms.glossary.at'} @:{'terms.glossary.du'} @:{'terms.glossary.vil'} @:{'terms.glossary.slette'} @:{'terms.glossary.dette'} @:{'terms.glossary.produkt'}?", "delete_confirm": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.sikker'} @:{'terms.glossary.pa'}, @:{'terms.glossary.at'} @:{'terms.glossary.du'} @:{'terms.glossary.vil'} @:{'terms.glossary.slette'} @:{'terms.glossary.dette'} @:{'terms.glossary.produkt'}?",
"edit": "@:{'phrases.compat.products.edit_product'}" "edit": "@:{'phrases.compat.products.edit_product'}"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL til kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
"notification_settings": "Notifikationer",
"notification_settings_desc": "Slack-webhooks til systemhændelser.",
"subtitle": "Konfiguration af Slack-notifikationer",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
"shelly": { "shelly": {
"api_key_desc": "@:{'terms.glossary.shelly'} @:{'terms.glossary.api'}-@:{'terms.glossary.nøkkelen'} @:{'terms.glossary.bruges'} @:{'terms.glossary.til'} @:{'terms.glossary.a_2'} @:{'terms.glossary.autentisere'} @:{'terms.glossary.shelly'}-@:{'terms.glossary.integrationen'}", "api_key_desc": "@:{'terms.glossary.shelly'} @:{'terms.glossary.api'}-@:{'terms.glossary.nøkkelen'} @:{'terms.glossary.bruges'} @:{'terms.glossary.til'} @:{'terms.glossary.a_2'} @:{'terms.glossary.autentisere'} @:{'terms.glossary.shelly'}-@:{'terms.glossary.integrationen'}",
"api_settings_desc": "@:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.forbindelsesindstillingerne'}.", "api_settings_desc": "@:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.forbindelsesindstillingerne'}.",
@@ -1,11 +0,0 @@
{
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlaes nu?",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
"version": "Nuværende {current} / seneste {latest}"
}
}
}
@@ -103,9 +103,7 @@
"email_notifications_desc": "@:{'terms.glossary.modtag'} @:{'terms.glossary.e'}-mails @:{'terms.glossary.med'} bookingbekræftelser @:{'terms.glossary.og'} @:{'terms.glossary.vaskecertifikater'}.", "email_notifications_desc": "@:{'terms.glossary.modtag'} @:{'terms.glossary.e'}-mails @:{'terms.glossary.med'} bookingbekræftelser @:{'terms.glossary.og'} @:{'terms.glossary.vaskecertifikater'}.",
"sms_notifications_desc": "@:{'terms.glossary.modtag'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'} @:{'terms.glossary.ved'} @:{'terms.glossary.forskellige'} @:{'terms.glossary.begivenheder'}.", "sms_notifications_desc": "@:{'terms.glossary.modtag'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'} @:{'terms.glossary.ved'} @:{'terms.glossary.forskellige'} @:{'terms.glossary.begivenheder'}.",
"sms_phone": "@.capitalize:{'terms.glossary.telefonnummer'} @:{'terms.glossary.til'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}", "sms_phone": "@.capitalize:{'terms.glossary.telefonnummer'} @:{'terms.glossary.til'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}",
"sms_phone_desc": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.der'} @:{'terms.glossary.modtager'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}.", "sms_phone_desc": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.der'} @:{'terms.glossary.modtager'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}."
"superuser_notifications": "Superuser-notifikationer",
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden."
}, },
"security": { "security": {
"description": "@.capitalize:{'terms.glossary.dine'} sikkerhedsindstillinger", "description": "@.capitalize:{'terms.glossary.dine'} sikkerhedsindstillinger",
@@ -1,21 +1,6 @@
{ {
"compat": { "compat": {
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.rechnung'} @:{'terms.glossary.ist'} @:{'terms.glossary.in'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}, @:{'terms.glossary.keine'} @:{'terms.glossary.weiteren'} @:{'terms.glossary.aktionen'} @:{'terms.glossary.sind'} @:{'terms.glossary.erforderlich'}.", "booked_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.rechnung'} @:{'terms.glossary.ist'} @:{'terms.glossary.in'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}, @:{'terms.glossary.keine'} @:{'terms.glossary.weiteren'} @:{'terms.glossary.aktionen'} @:{'terms.glossary.sind'} @:{'terms.glossary.erforderlich'}.",
"booked_with_economic": "@:{'terms.glossary.rechnung'} @:{'terms.glossary.mit'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}", "booked_with_economic": "@:{'terms.glossary.rechnung'} @:{'terms.glossary.mit'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}",
@@ -158,15 +158,6 @@
"delete_confirm": "@.capitalize:{'terms.glossary.sind'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.sicher'}, @:{'terms.glossary.dass'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.dieses'} @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.l'}?@:{'terms.glossary.schen'} @:{'terms.glossary.m'}?@:{'terms.glossary.chten'}?", "delete_confirm": "@.capitalize:{'terms.glossary.sind'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.sicher'}, @:{'terms.glossary.dass'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.dieses'} @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.l'}?@:{'terms.glossary.schen'} @:{'terms.glossary.m'}?@:{'terms.glossary.chten'}?",
"edit": "@.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.bearbeiten'}" "edit": "@.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.bearbeiten'}"
}, },
"slack": {
"customer_registration_webhook_url": "Webhook-URL fuer Kundenregistrierungen",
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
"notification_settings": "Benachrichtigungseinstellungen",
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
"title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
"shelly": { "shelly": {
"api_key_desc": "@.capitalize:{'terms.glossary.der'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'}-@:{'terms.glossary.schl'}?@:{'terms.glossary.ssel'} @:{'terms.glossary.wird'} @:{'terms.glossary.zur'} @:{'terms.glossary.authentifizierung'} @:{'terms.glossary.der'} @:{'terms.glossary.shelly'}-@.capitalize:{'terms.glossary.integration'} @:{'terms.glossary.verwendet'}", "api_key_desc": "@.capitalize:{'terms.glossary.der'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'}-@:{'terms.glossary.schl'}?@:{'terms.glossary.ssel'} @:{'terms.glossary.wird'} @:{'terms.glossary.zur'} @:{'terms.glossary.authentifizierung'} @:{'terms.glossary.der'} @:{'terms.glossary.shelly'}-@.capitalize:{'terms.glossary.integration'} @:{'terms.glossary.verwendet'}",
"api_settings_desc": "@:{'terms.glossary.api'}-@:{'terms.glossary.verbindungseinstellungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.shelly'}.", "api_settings_desc": "@:{'terms.glossary.api'}-@:{'terms.glossary.verbindungseinstellungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.shelly'}.",
@@ -1,11 +0,0 @@
{
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
"version": "Aktuell {current} / neueste {latest}"
}
}
}
@@ -103,9 +103,7 @@
"email_notifications_desc": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mails'} @:{'terms.glossary.mit'} @:{'terms.glossary.buchungsbest'}?@:{'terms.glossary.tigungen'} @:{'terms.glossary.und'} Waschzertifikaten @:{'terms.glossary.erhalten'}.", "email_notifications_desc": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mails'} @:{'terms.glossary.mit'} @:{'terms.glossary.buchungsbest'}?@:{'terms.glossary.tigungen'} @:{'terms.glossary.und'} Waschzertifikaten @:{'terms.glossary.erhalten'}.",
"sms_notifications_desc": "@:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.verschiedene'} @:{'terms.glossary.ereignisse'} @:{'terms.glossary.erhalten'}.", "sms_notifications_desc": "@:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.verschiedene'} @:{'terms.glossary.ereignisse'} @:{'terms.glossary.erhalten'}.",
"sms_phone": "@:{'terms.glossary.telefonnummer'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'}", "sms_phone": "@:{'terms.glossary.telefonnummer'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'}",
"sms_phone_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.die'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.erh'}?@:{'terms.glossary.lt'}.", "sms_phone_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.die'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.erh'}?@:{'terms.glossary.lt'}."
"superuser_notifications": "Superuser-Benachrichtigungen",
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert."
}, },
"security": { "security": {
"description": "@.capitalize:{'terms.glossary.ihre'} Sicherheitseinstellungen", "description": "@.capitalize:{'terms.glossary.ihre'} Sicherheitseinstellungen",
@@ -1,21 +1,6 @@
{ {
"compat": { "compat": {
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.invoice'} @:{'terms.glossary.is'} @:{'terms.glossary.booked'} @:{'terms.glossary.in'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.no'} @:{'terms.glossary.further'} @:{'terms.glossary.actions'} @:{'terms.glossary.are'} @:{'terms.glossary.required'}.", "booked_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.invoice'} @:{'terms.glossary.is'} @:{'terms.glossary.booked'} @:{'terms.glossary.in'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.no'} @:{'terms.glossary.further'} @:{'terms.glossary.actions'} @:{'terms.glossary.are'} @:{'terms.glossary.required'}.",
"booked_with_economic": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.booked'} @:{'terms.glossary.with'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}", "booked_with_economic": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.booked'} @:{'terms.glossary.with'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}",
@@ -158,15 +158,6 @@
"delete_confirm": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.sure'} @:{'terms.glossary.you'} @:{'terms.glossary.want'} @:{'terms.glossary.to'} @:{'terms.glossary.delete'} @:{'terms.glossary.this'} @:{'terms.glossary.product'}?", "delete_confirm": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.sure'} @:{'terms.glossary.you'} @:{'terms.glossary.want'} @:{'terms.glossary.to'} @:{'terms.glossary.delete'} @:{'terms.glossary.this'} @:{'terms.glossary.product'}?",
"edit": "@:{'phrases.compat.products.edit_product'}" "edit": "@:{'phrases.compat.products.edit_product'}"
}, },
"slack": {
"customer_registration_webhook_url": "Customer registration webhook URL",
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
"notification_settings": "Notification settings",
"notification_settings_desc": "Slack webhooks for system events.",
"subtitle": "Configuration of Slack notifications",
"title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release."
},
"shelly": { "shelly": {
"api_key_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.key'} @:{'terms.glossary.is'} @:{'terms.glossary.used'} @:{'terms.glossary.to'} @:{'terms.glossary.authenticate'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.integration'}", "api_key_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.key'} @:{'terms.glossary.is'} @:{'terms.glossary.used'} @:{'terms.glossary.to'} @:{'terms.glossary.authenticate'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.integration'}",
"api_settings_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.connection'} @:{'terms.glossary.settings'}.", "api_settings_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.connection'} @:{'terms.glossary.settings'}.",
@@ -1,11 +0,0 @@
{
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
"version": "Current {current} / latest {latest}"
}
}
}
@@ -103,9 +103,7 @@
"email_notifications_desc": "@.capitalize:{'terms.glossary.receive'} @:{'terms.glossary.emails'} @:{'terms.glossary.with'} @:{'terms.glossary.booking'} @:{'terms.glossary.confirmations'} @:{'terms.glossary.and'} @:{'terms.glossary.wash'} @:{'terms.glossary.certificates'}.", "email_notifications_desc": "@.capitalize:{'terms.glossary.receive'} @:{'terms.glossary.emails'} @:{'terms.glossary.with'} @:{'terms.glossary.booking'} @:{'terms.glossary.confirmations'} @:{'terms.glossary.and'} @:{'terms.glossary.wash'} @:{'terms.glossary.certificates'}.",
"sms_notifications_desc": "@.capitalize:{'terms.glossary.receive'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'} @:{'terms.glossary.for'} @:{'terms.glossary.various'} @:{'terms.glossary.events'}.", "sms_notifications_desc": "@.capitalize:{'terms.glossary.receive'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'} @:{'terms.glossary.for'} @:{'terms.glossary.various'} @:{'terms.glossary.events'}.",
"sms_phone": "@.capitalize:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.for'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}", "sms_phone": "@.capitalize:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.for'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}",
"sms_phone_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.that'} @:{'terms.glossary.receives'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}.", "sms_phone_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.that'} @:{'terms.glossary.receives'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}."
"superuser_notifications": "Superuser notifications",
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site."
}, },
"security": { "security": {
"description": "@.capitalize:{'terms.glossary.your'} @:{'terms.glossary.security'} @:{'terms.glossary.settings'}", "description": "@.capitalize:{'terms.glossary.your'} @:{'terms.glossary.security'} @:{'terms.glossary.settings'}",
@@ -1,20 +1,5 @@
{ {
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "@:{'phrases.compat.collected_invoice.move_customer.card_title'}",
"confirm_button": "@:{'phrases.compat.collected_invoice.move_customer.confirm_button'}",
"confirm_text": "@:{'phrases.compat.collected_invoice.move_customer.confirm_text'}",
"confirm_title": "@:{'phrases.compat.collected_invoice.move_customer.confirm_title'}",
"current_customer": "@:{'phrases.compat.collected_invoice.move_customer.current_customer'}",
"customer_value": "@:{'phrases.compat.collected_invoice.move_customer.customer_value'}",
"error_title": "@:{'phrases.compat.collected_invoice.move_customer.error_title'}",
"invalid_customer": "@:{'phrases.compat.collected_invoice.move_customer.invalid_customer'}",
"locked": "@:{'phrases.compat.collected_invoice.move_customer.locked'}",
"same_customer": "@:{'phrases.compat.collected_invoice.move_customer.same_customer'}",
"success_text": "@:{'phrases.compat.collected_invoice.move_customer.success_text'}",
"success_title": "@:{'phrases.compat.collected_invoice.move_customer.success_title'}",
"title": "@:{'phrases.compat.collected_invoice.move_customer.title'}"
},
"economic": { "economic": {
"booked_desc": "@:{'phrases.compat.collected_invoice.economic.booked_desc'}", "booked_desc": "@:{'phrases.compat.collected_invoice.economic.booked_desc'}",
"booked_with_economic": "@:{'phrases.compat.collected_invoice.economic.booked_with_economic'}", "booked_with_economic": "@:{'phrases.compat.collected_invoice.economic.booked_with_economic'}",
@@ -184,15 +184,6 @@
"subtitle": "@:products.subtitle", "subtitle": "@:products.subtitle",
"title": "@:common.products" "title": "@:common.products"
}, },
"slack": {
"customer_registration_webhook_url": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url'}",
"customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}",
"subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}",
"title": "@:{'phrases.compat.configuration.slack.title'}",
"unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}"
},
"shelly": { "shelly": {
"api_key": "@:configuration.limble.api_key", "api_key": "@:configuration.limble.api_key",
"api_key_desc": "@:{'phrases.compat.configuration.shelly.api_key_desc'}", "api_key_desc": "@:{'phrases.compat.configuration.shelly.api_key_desc'}",
@@ -1,9 +0,0 @@
{
"maintenance_menu": {
"confirm_clear_local": "@:{'phrases.compat.maintenance_menu.confirm_clear_local'}",
"force_update_clear_hint": "@:{'phrases.compat.maintenance_menu.force_update_clear_hint'}",
"force_update_clear": "@:{'phrases.compat.maintenance_menu.force_update_clear'}",
"title": "@:{'phrases.compat.maintenance_menu.title'}",
"version": "@:{'phrases.compat.maintenance_menu.version'}"
}
}
@@ -38,7 +38,6 @@
"department_relays": "@:{'phrases.compat.superuser.nav.department_relays'}", "department_relays": "@:{'phrases.compat.superuser.nav.department_relays'}",
"departments": "@:common.departments", "departments": "@:common.departments",
"employees": "@:{'phrases.compat.common.roless.employee'}", "employees": "@:{'phrases.compat.common.roless.employee'}",
"error_reports": "@:{'phrases.compat.superuser.nav.error_reports'}",
"invoices": "@:common.invoices", "invoices": "@:common.invoices",
"invoicing": "@:{'phrases.compat.superuser.nav.invoicing'}", "invoicing": "@:{'phrases.compat.superuser.nav.invoicing'}",
"orders": "@:{'phrases.compat.superuser.nav.orders'}", "orders": "@:{'phrases.compat.superuser.nav.orders'}",
@@ -147,8 +147,6 @@
"sms_phone": "@:{'phrases.compat.user_dashboard.profile.notifications.sms_phone'}", "sms_phone": "@:{'phrases.compat.user_dashboard.profile.notifications.sms_phone'}",
"sms_phone_desc": "@:{'phrases.compat.user_dashboard.profile.notifications.sms_phone_desc'}", "sms_phone_desc": "@:{'phrases.compat.user_dashboard.profile.notifications.sms_phone_desc'}",
"sms_subtitle": "@:time_booking_flow.sms", "sms_subtitle": "@:time_booking_flow.sms",
"superuser_notifications": "@:{'phrases.compat.user_dashboard.profile.notifications.superuser_notifications'}",
"superuser_notifications_desc": "@:{'phrases.compat.user_dashboard.profile.notifications.superuser_notifications_desc'}",
"title": "@:nav.notifications" "title": "@:nav.notifications"
}, },
"security": { "security": {
@@ -1,21 +1,6 @@
{ {
"compat": { "compat": {
"collected_invoice": { "collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": { "economic": {
"booked_desc": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bokført'} @:{'terms.glossary.i'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.ingen'} @:{'terms.glossary.ytterligere'} @:{'terms.glossary.handlinger'} @:{'terms.glossary.er'} @:{'terms.glossary.nødvendig'}.", "booked_desc": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bokført'} @:{'terms.glossary.i'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.ingen'} @:{'terms.glossary.ytterligere'} @:{'terms.glossary.handlinger'} @:{'terms.glossary.er'} @:{'terms.glossary.nødvendig'}.",
"booked_with_economic": "@.capitalize:{'terms.glossary.faktura'} @:{'terms.glossary.bestilles'} @:{'terms.glossary.hos'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}", "booked_with_economic": "@.capitalize:{'terms.glossary.faktura'} @:{'terms.glossary.bestilles'} @:{'terms.glossary.hos'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}",

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