Compare commits

..
Author SHA1 Message Date
Jeppe B d33305d8e4 Prepare frontend master protection 2026-07-16 12:43:59 +02:00
Jeppe B 20c383d22d Format customer rule tests 2026-07-16 12:11:06 +02:00
Jeppe B 77a0c4e018 Add customer rule product restrictions 2026-07-16 11:51:42 +02:00
Jeppe B 9a5c8b193d style: format admin order filter test 2026-07-16 11:50:41 +02:00
Jeppe B 0562ba8ab0 feat: add admin order filters 2026-07-16 11:45:08 +02:00
Jeppe Bundgaard 906e860c86 test: distinguish order table requests 2026-07-16 01:29:02 +02:00
Jeppe B 516e1fb58d Expose invoicing flag badge translations 2026-07-15 22:47:11 +02:00
Jeppe B 7da1307cf6 Restore i18n locale compatibility keys 2026-07-15 21:37:21 +02:00
Jeppe B ea4893d815 Ignore warm prefetches in month selection E2E 2026-07-15 20:50:02 +02:00
Jeppe B f2e0079b59 Stabilize invoicing period E2E scenarios 2026-07-15 20:26:49 +02:00
Jeppe B 47d8baa496 Format frontend test files 2026-07-15 19:42:27 +02:00
Jeppe B 6d96d64811 Improve invoicing period tree and POS restrictions 2026-07-15 17:04:51 +02:00
Jeppe B 92fb998e5a Fix customer additional-service restrictions (#171) 2026-07-15 15:08:01 +02:00
Jeppe B bed6030e2b Stabilize invoicing period date fixtures 2026-07-15 02:45:02 +02:00
Jeppe B 328a85bad1 Review object tree dynamic i18n helper
Add the invoicing period object tree translation wrapper to the reviewed non-literal call list; it mirrors the existing flag-list helper pattern.
2026-07-15 01:30:18 +02:00
Jeppe B 394375e429 Fix user profile two-factor test id
Keep the profile two-factor wrapper test id stable and move spacing tokens to class so the mobile profile security E2E can find the status element.
2026-07-15 00:17:34 +02:00
Jeppe B ca744adfd9 Link security status filter i18n label
Reuse the canonical system status label for the security incident status filter so the v2 duplicate raw phrase gate stays green.
2026-07-14 23:35:53 +02:00
Jeppe B 5e42f55570 Fix invoicing period object-tree flag handling
Keep expanded period rows on InvoicingPeriodObjectTree while rendering order-scoped flags in a compact expanded flag list. Update the invoicing-period E2E fixtures and source contract for the object-tree path.
2026-07-14 22:39:33 +02:00
Jeppe B 021da1a074 Fix invoicing period expanded order details (#170) 2026-07-14 21:02:22 +02:00
Jeppe B 0e55f45e91 Fix invoicing queue-state unit spec 2026-07-14 16:05:04 +02:00
Jeppe B 9749837506 Format frontend test files 2026-07-14 15:55:17 +02:00
Jeppe B 4fbb0b98c3 Improve superuser invoicing and system status views 2026-07-14 15:41:05 +02:00
Jeppe B 0edf8afcaf Regenerate Danish i18n runtime 2026-07-14 13:52:23 +02:00
gold-dev e2ee54578c Feat: 202607 - truckwash.dk V15_sent slide 8 2026-07-14 12:28:56 +03:00
gold-dev b35e4484de Merge remote-tracking branch 'origin/master' 2026-07-14 12:15:00 +03:00
gold-dev c8368612eb Feat: 202607 - truckwash.dk V15_sent slide 6, 7 2026-07-14 12:14:47 +03:00
Jeppe B 26266e724b Stabilize customer booking and i18n checks 2026-07-14 10:34:29 +02:00
Jeppe B 6ccfea9eb4 Limit security nav assertion to desktop layout 2026-07-14 02:15:44 +02:00
Jeppe B d4349c20a7 Format subuser management spec 2026-07-14 00:59:12 +02:00
Jeppe Bundgaard 63f1cc2450 Fix custom subuser Dognvask updates 2026-07-13 22:30:18 +02:00
Jeppe Bundgaard acec2a694a Add loading screen for session confirmation and enhance vehicle management labels 2026-07-13 22:11:51 +02:00
Jeppe B f15d7224e0 Make failover wiring source check formatter tolerant 2026-07-13 21:50:28 +02:00
Jeppe B 236f552a22 Stabilize subuser QR auth smoke assertion 2026-07-13 20:57:30 +02:00
Jeppe B 321b6f2c64 Format auth smoke session bootstrap test 2026-07-13 20:36:03 +02:00
Jeppe B 218ead9a31 Fix subuser QR session bootstrap 2026-07-13 20:33:48 +02:00
Jeppe B 60b6e82c88 Dedupe cron worker target i18n key 2026-07-13 20:32:03 +02:00
Jeppe B bbe4ad938a Add cron worker controls and align superuser specs 2026-07-13 19:47:08 +02:00
Jeppe B 9e27380a10 Fix i18n v2 catalog integrity drift
Fill missing backup compatibility keys for Danish and Norwegian and centralize repeated shared/support labels through existing i18n links.
2026-07-13 17:59:51 +02:00
Jeppe Bundgaard baf83fd079 Restore gateway smoke helpers after route spec cleanup
Keep the gateway health-card smoke test runnable after removing the stale replication-management helper block.
2026-07-13 15:02:15 +00:00
Jeppe Bundgaard dfe3163692 Align frontend smoke tests with current route permissions
Update self-serve mocked customer permissions for the guarded wash routes and replace removed replication-management E2E coverage with dashboard redirect assertions.
2026-07-13 16:41:08 +02:00
Jeppe Bundgaard 4452bd4088 Update system status route contract test 2026-07-13 15:57:42 +02:00
Jeppe Bundgaard 6f93b840a9 Relax superuser module wiring assertions 2026-07-13 15:45:59 +02:00
Jeppe Bundgaard 443f50c144 Enhance subuser management contact and action flows 2026-07-13 15:11:49 +02:00
Jeppe Bundgaard 5a7f902d01 Enhance subuser action menu with new sections and labels for improved user experience 2026-07-13 10:42:13 +02:00
Jeppe Bundgaard 02cc82cee1 Enhance pagination filter dropdown styling and update subuser management tests 2026-07-13 10:27:05 +02:00
Jeppe Bundgaard fa9b05958d Enhance subuser management functionality and improve UI responsiveness 2026-07-13 10:25:20 +02:00
Jeppe Bundgaard 6fb2c9e7df Update self-serve permissions and enhance UI components for customer interactions 2026-07-13 10:21:18 +02:00
Jeppe Bundgaard 54e59b4b3e Add system status displays for Minio and Redis, and enhance backup configuration 2026-07-13 10:07:56 +02:00
Jeppe B 8a0ea6cae5 Fix cron status translation literals 2026-07-13 08:48:45 +02:00
Jeppe B 1bbd816e83 Fix session bootstrap heading assertion
Target the system status heading in the delayed session bootstrap test so Playwright strict mode does not match both the navigation link and page heading.
2026-07-09 16:17:33 +02:00
Jeppe B 4e56191b7e Fix cron due i18n link target
Add locale glossary entries for the cron due label and link the source alias through terms.glossary.due so generated runtime links resolve across active locales.
2026-07-09 14:51:17 +02:00
Jeppe B 4404fb49d3 Fix cron i18n duplicate aliases
Link repeated cron labels through existing cron/shared i18n aliases and regenerate global-v2.json so i18n integrity checks pass.
2026-07-09 13:00:25 +02:00
Jeppe B fd4db442e4 Add Playwright component test quarantine flow 2026-07-09 11:29:44 +02:00
Jeppe B 416f392108 Classify superuser cron Playwright spec 2026-07-09 11:26:59 +02:00
Jeppe B dbe9f6b0b2 Redirect QR signups to kundeoprettelse
Redirect legacy QR signup routes for new customers and drivers to the shared /kundeoprettelse customer creation page.

Validated with focused eslint and QR Playwright coverage for mobile and desktop.
2026-07-09 11:19:30 +02:00
Jeppe B 18f06f7a63 Format POS visual e2e spec 2026-07-09 11:19:23 +02:00
Jeppe B fb75ddc3e9 Format superuser cron e2e spec 2026-07-09 11:15:29 +02:00
Jeppe B 1326a40033 Add superuser cron operations UI 2026-07-09 11:12:02 +02:00
Jeppe Bundgaard 85be8d2ce7 Fix Android launcher icon assets
Regenerate Android launcher icons from the blue striped source asset.
Add a repeatable generator and README documentation for the icon workflow.
2026-07-09 10:52:56 +02:00
Jeppe Bundgaard b5fb8c8cac Fix frontend master CI runner failures 2026-07-09 10:50:11 +02:00
Jeppe Bundgaard 71b7ec4987 Fix invoicing period department 75 table overflow 2026-07-08 20:38:39 +02:00
Jeppe Bundgaard ad110d46d9 Fix POS order table primary registration display 2026-07-08 19:23:20 +02:00
Jeppe B dabe0c13ef Format mobile POS addon quantity update 2026-07-08 18:52:23 +02:00
Jeppe B a5dbfb5cba Fix mobile POS additional item product selection 2026-07-08 18:48:44 +02:00
Jeppe B 75c4700ac6 Fix mobile POS addon quantity updates 2026-07-08 18:47:08 +02:00
Jeppe B 19ce60fd0a Fix orders table formatting and unit expectation 2026-07-08 17:02:59 +02:00
Jeppe Bundgaard d26933b722 Enhance orders table with compact text cells and add test for font size consistency 2026-07-08 16:57:13 +02:00
Jeppe B 30dfe2c0c0 Stabilize frontend E2E selectors 2026-07-08 16:53:52 +02:00
Jeppe Bundgaard fa55bcd5f3 Add date cell to orders table and update styling for better visibility 2026-07-08 16:01:27 +02:00
Jeppe B fbc1dc9406 Update view i18n dynamic key allowlist 2026-07-08 15:54:41 +02:00
Jeppe B 4a4b82c8b6 Format invoice bulk actions changes 2026-07-08 15:52:06 +02:00
Jeppe Bundgaard 042e477252 Add invoice collection bulk actions and table header preferences functionality 2026-07-08 15:43:14 +02:00
Jeppe B d1d7b72441 Format duplicate-date E2E assertion 2026-07-08 14:56:29 +02:00
Jeppe B 48d8d7f527 Stabilize mobile superuser E2E assertions 2026-07-08 14:54:17 +02:00
Jeppe B 73a71e5262 Sync i18n v2 generated runtime files 2026-07-08 14:01:10 +02:00
Jeppe B cab779e403 Align Danish vehicle action unit expectation 2026-07-08 13:54:09 +02:00
Jeppe Bundgaard e51e874264 Merge remote-tracking branch 'origin/master' 2026-07-08 13:52:15 +02:00
Jeppe Bundgaard e399c9f974 Add bulk action preview and apply functionality for invoice collections 2026-07-08 13:51:54 +02:00
Jeppe B acd46e5f2a Fix i18n locale integrity coverage 2026-07-08 13:43:37 +02:00
Jeppe B 59af8453f3 Fix i18n word dedupe casing variants 2026-07-08 13:04:41 +02:00
Jeppe Bundgaard d03f4d9aae Add subusers management features and enhance department navigation 2026-07-08 12:27:45 +02:00
Jeppe B 4cc2c3310c Fix frontend test gate expectations 2026-07-07 22:16:25 +02:00
Jeppe Bundgaard 1123b34e10 Enhance UI components and improve date selection functionality 2026-07-07 20:52:14 +02:00
Jeppe Bundgaard 8c12fce187 Add customer pricing labels and update date selection components 2026-07-07 18:16:45 +02:00
Jeppe Bundgaard a82176c855 Merge limited backoffice role template actions 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard 7180bbd6d4 Add limited backoffice role template actions 2026-07-07 03:23:32 +02:00
Copilotandcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> b9b4539764 ci: run automated tests on main and dev pushes (#165)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 01:56:19 +02:00
Jeppe BandJeppe Bundgaard 96ccc01d08 Add limited backoffice employee migration (#164)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-07 00:14:05 +02:00
Jeppe Bundgaard 2108dd6c94 Send Selvvask import period dates
Include selected invoice-period dates when importing XLVask usage from the Selvvask period view.
2026-07-06 23:49:36 +02:00
Jeppe BandJeppe Bundgaard ca2003f2d6 Allow hosted runners for manual frontend tests (#161)
* Allow hosted runners for manual frontend tests

* Fix hosted full E2E i18n failures

* Stabilize release assignment E2E selection

* Gate full E2E on focused regressions

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:38:29 +02:00
552 changed files with 263372 additions and 15008 deletions
+50
View File
@@ -0,0 +1,50 @@
# Default branch protection
The intended repository ruleset is stored in
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json).
It targets the configured default branch and requires pull requests, the strict
`Required CI` check from GitHub Actions, resolved review conversations,
squash-only merges, and linear history. Branch deletion and force pushes are
blocked. Qodana remains advisory and is not part of the required gate.
The ruleset's `RepositoryRole` actor ID `5` is GitHub's built-in Administrator
role. Its `pull_request` bypass mode permits an administrator to bypass rules
only while merging an existing pull request; it does not permit a direct push.
## Repository settings
Keep squash merge enabled and disable merge commits and rebase merge. Enable
auto-merge, the update-branch option, and automatic deletion of merged head
branches. Keep the Actions token read-only and do not allow Actions to approve
pull-request reviews.
## Activation and verification
1. Confirm a pull request and a `master` push each produce exactly one
successful `Required CI` check from GitHub Actions integration `15368`.
2. For the initial ruleset POST, override the committed JSON's `enforcement`
value to `disabled`, then compare GitHub's normalized API response with this
file.
3. PUT the exact committed JSON to the inspected ruleset to activate it.
4. Open a canary pull request and confirm that pending or failing CI, unresolved
conversations, and an out-of-date branch block merging; only squash merge is
available.
5. After merging, confirm the head branch is deleted and the post-merge full
E2E, frontend release, and mobile release guards still run.
If validation exposes a blocker, disable the ruleset rather than deleting it so
its configuration and history remain available.
## Normal publishing flow
Create a scoped feature branch, open a pull request to `master`, wait for
`Required CI`, update the branch if `master` advanced, resolve every review
conversation, and squash-merge. For waits expected to exceed 90 seconds, use
the workspace `scripts/ci-watch.sh` helper instead of repeatedly polling GitHub.
## Break glass
For an incident, an administrator must still open a pull request. Document the
incident and why the normal gate cannot complete, then use the PR-only bypass
when merging. Monitor all post-merge workflows and open a follow-up pull request
for any validation or remediation deferred during the incident.
@@ -0,0 +1,53 @@
{
"name": "Protect default branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
],
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "required_linear_history"
},
{
"type": "pull_request",
"parameters": {
"allowed_merge_methods": ["squash"],
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_approving_review_count": 0,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "Required CI",
"integration_id": 15368
}
],
"strict_required_status_checks_policy": true
}
}
]
}
+35 -2
View File
@@ -2,9 +2,9 @@ name: Qodana Configuration Upload
on: on:
push: push:
branches: [main, dev] branches: [master, beta, canary, internal]
pull_request: pull_request:
branches: [main] branches: [master, beta, canary, internal]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -19,7 +19,34 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Detect Qodana upload prerequisites
id: qodana-upload-prerequisites
shell: bash
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
set -euo pipefail
config_present=false
token_present=false
[[ -f qodana-global-configurations.yaml ]] && config_present=true
[[ -n "${QODANA_CONFIGURATIONS_TOKEN:-}" ]] && token_present=true
if [[ "$config_present" == true && "$token_present" == true ]]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
echo "reason=all prerequisites are configured" >> "$GITHUB_OUTPUT"
elif [[ "$config_present" != true && "$token_present" != true ]]; then
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "reason=qodana-global-configurations.yaml and QODANA_CONFIGURATIONS_TOKEN are missing" >> "$GITHUB_OUTPUT"
elif [[ "$config_present" != true ]]; then
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "reason=qodana-global-configurations.yaml is missing" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "reason=QODANA_CONFIGURATIONS_TOKEN is missing" >> "$GITHUB_OUTPUT"
fi
- name: Run Qodana Configuration Uploader - name: Run Qodana Configuration Uploader
if: ${{ steps.qodana-upload-prerequisites.outputs.ready == 'true' }}
env: env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }} QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: | run: |
@@ -30,3 +57,9 @@ jobs:
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \ jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \ --global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud --qodana-host https://qodana.cloud
- name: Skip Qodana Configuration Upload
if: ${{ steps.qodana-upload-prerequisites.outputs.ready != 'true' }}
env:
QODANA_SKIP_REASON: ${{ steps.qodana-upload-prerequisites.outputs.reason }}
run: echo "Skipping Qodana configuration upload because ${QODANA_SKIP_REASON}."
+223 -30
View File
@@ -11,6 +11,36 @@ on:
description: Store build number/version code description: Store build number/version code
required: false required: false
type: string type: string
upload_android_to_play:
description: Upload the signed Android App Bundle to Google Play
required: false
type: boolean
default: true
upload_ios_to_app_store:
description: Upload the signed iOS IPA to App Store Connect
required: false
type: boolean
default: true
android_track:
description: Google Play track for manual dispatches
required: false
type: choice
default: production
options:
- production
- beta
- alpha
- internal
android_release_status:
description: Google Play release status for manual dispatches
required: false
type: choice
default: completed
options:
- completed
- draft
- inProgress
- halted
push: push:
tags: tags:
- "mobile-v*" - "mobile-v*"
@@ -26,40 +56,81 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: mobile-store-artifacts-${{ github.ref_name }} group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
android: android:
name: Android AAB name: Android AAB and Play upload
if: > if: >
github.event_name != 'workflow_run' || github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch) github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 45 environment: mobile-store-production
timeout-minutes: 60
env:
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }} ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
if: steps.release-guard.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: Setup Java - name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
- name: Setup Android SDK - name: Setup Android SDK
if: steps.release-guard.outputs.current == 'true'
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: Install Android SDK packages - name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -67,10 +138,11 @@ jobs:
sdkmanager "platforms;android-36" "build-tools;36.0.0" sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Resolve mobile version - name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
INPUT_VERSION_NAME: ${{ inputs.version_name }} INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code }} INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: | run: |
set -euo pipefail set -euo pipefail
version_name="$INPUT_VERSION_NAME" version_name="$INPUT_VERSION_NAME"
@@ -84,10 +156,22 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV" echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check Android store environment
if: steps.release-guard.outputs.current == 'true'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --android
- name: Install dependencies - name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Decode Android signing key - name: Decode Android signing key
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -96,10 +180,6 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: | run: |
set -euo pipefail set -euo pipefail
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
keystore_path="$RUNNER_TEMP/android-release.keystore" keystore_path="$RUNNER_TEMP/android-release.keystore"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path" node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path"
echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV"
@@ -108,48 +188,98 @@ jobs:
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV" echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
- name: Build and sync Android shell - name: Build and sync Android shell
if: steps.release-guard.outputs.current == 'true'
run: | run: |
npm run mobile:android:sync npm run mobile:android:sync
npm run mobile:permissions:check npm run mobile:permissions:check
npm run mobile:android:signing:check npm run mobile:android:signing:check
- name: Build signed Android App Bundle - name: Build signed Android App Bundle
if: steps.release-guard.outputs.current == 'true'
working-directory: android working-directory: android
run: ./gradlew --no-daemon bundleRelease run: ./gradlew --no-daemon bundleRelease
- name: Verify Android App Bundle signature - name: Verify Android App Bundle signature
run: jarsigner -verify -certs -verbose android/app/build/outputs/bundle/release/app-release.aab >/dev/null if: steps.release-guard.outputs.current == 'true'
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
- name: Upload Android artifact - name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }} name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: android/app/build/outputs/bundle/release/app-release.aab path: ${{ env.ANDROID_AAB_PATH }}
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
- name: Upload Android App Bundle to Google Play
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: npm run mobile:android:play-upload
ios: ios:
name: iOS IPA name: iOS IPA and App Store upload
if: github.event_name != 'workflow_run' if: >
runs-on: macos-latest github.event_name != 'workflow_run' ||
timeout-minutes: 60 (github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: macos-15
environment: mobile-store-production
timeout-minutes: 90
env:
IOS_PROJECT_PATH: ios/App/App.xcodeproj
IOS_SCHEME: App
IOS_BUNDLE_ID: io.truckwash.app
UPLOAD_IOS_TO_APP_STORE: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_ios_to_app_store }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ github.sha }} ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
if: steps.release-guard.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: Resolve mobile version - name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
INPUT_VERSION_NAME: ${{ inputs.version_name }} INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code }} INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: | run: |
set -euo pipefail set -euo pipefail
version_name="$INPUT_VERSION_NAME" version_name="$INPUT_VERSION_NAME"
@@ -163,16 +293,32 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV" echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check iOS store environment
if: steps.release-guard.outputs.current == 'true'
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --ios
- name: Install dependencies - name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Build and sync iOS shell - name: Build and sync iOS shell
if: steps.release-guard.outputs.current == 'true'
run: | run: |
npm run build npm run build
npx cap sync ios npx cap sync ios
npm run mobile:permissions:check npm run mobile:permissions:check
- name: Install Apple signing assets - name: Install Apple signing assets
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
@@ -182,12 +328,6 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: | run: |
set -euo pipefail set -euo pipefail
test -n "$IOS_CERTIFICATE_BASE64"
test -n "$IOS_CERTIFICATE_PASSWORD"
test -n "$IOS_PROVISION_PROFILE_BASE64"
test -n "$IOS_KEYCHAIN_PASSWORD"
test -n "$APPLE_TEAM_ID"
certificate_path="$RUNNER_TEMP/apple-distribution.p12" certificate_path="$RUNNER_TEMP/apple-distribution.p12"
profile_path="$RUNNER_TEMP/app-store.mobileprovision" profile_path="$RUNNER_TEMP/app-store.mobileprovision"
keychain_path="$RUNNER_TEMP/app-signing.keychain-db" keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
@@ -214,14 +354,35 @@ jobs:
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV" echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV" echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
- name: Install App Store Connect API key
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: |
set -euo pipefail
private_keys_dir="$RUNNER_TEMP/private_keys"
private_key_path="$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
mkdir -p "$private_keys_dir"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64, 'base64'))" "$private_key_path"
chmod 600 "$private_key_path"
echo "API_PRIVATE_KEYS_DIR=$private_keys_dir" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_ID=$APP_STORE_CONNECT_API_KEY_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_PATH=$private_key_path" >> "$GITHUB_ENV"
- name: Resolve Swift packages - name: Resolve Swift packages
run: xcodebuild -resolvePackageDependencies -project ios/App/App.xcodeproj -scheme App if: steps.release-guard.outputs.current == 'true'
run: xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Archive iOS app - name: Archive iOS app
if: steps.release-guard.outputs.current == 'true'
run: | run: |
xcodebuild \ xcodebuild \
-project ios/App/App.xcodeproj \ -project "$IOS_PROJECT_PATH" \
-scheme App \ -scheme "$IOS_SCHEME" \
-configuration Release \ -configuration Release \
-destination "generic/platform=iOS" \ -destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \ -archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
@@ -234,6 +395,7 @@ jobs:
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
- name: Export iOS IPA - name: Export iOS IPA
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -255,7 +417,7 @@ jobs:
<string>$APPLE_TEAM_ID</string> <string>$APPLE_TEAM_ID</string>
<key>provisioningProfiles</key> <key>provisioningProfiles</key>
<dict> <dict>
<key>io.truckwash.app</key> <key>$IOS_BUNDLE_ID</key>
<string>$IOS_PROFILE_NAME</string> <string>$IOS_PROFILE_NAME</string>
</dict> </dict>
<key>stripSwiftSymbols</key> <key>stripSwiftSymbols</key>
@@ -270,15 +432,43 @@ jobs:
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \ -archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
-exportPath "$RUNNER_TEMP/ios-export" \ -exportPath "$RUNNER_TEMP/ios-export" \
-exportOptionsPlist "$export_options" -exportOptionsPlist "$export_options"
ipa_path="$(find "$RUNNER_TEMP/ios-export" -name '*.ipa' -print -quit)"
test -n "$ipa_path"
echo "IOS_IPA_PATH=$ipa_path" >> "$GITHUB_ENV"
- name: Upload iOS artifact - name: Upload iOS artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }} name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ runner.temp }}/ios-export/*.ipa path: ${{ runner.temp }}/ios-export/*.ipa
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
- name: Validate iOS IPA with App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--validate-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Upload iOS IPA to App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--upload-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Clean up Apple signing assets - name: Clean up Apple signing assets
if: always() if: always()
shell: bash shell: bash
@@ -289,3 +479,6 @@ jobs:
if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision" rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
fi fi
if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then
rm -f "$APP_STORE_CONNECT_API_KEY_PATH"
fi
+30 -2
View File
@@ -4,7 +4,9 @@ on:
pull_request: pull_request:
push: push:
branches: branches:
- main
- master - master
- dev
workflow_dispatch: workflow_dispatch:
inputs: inputs:
mode: mode:
@@ -46,8 +48,8 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }} group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: true cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Repository variables used as CI runner and credit controls: # Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs. # - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
@@ -484,6 +486,7 @@ jobs:
install_dependencies install_dependencies
ulimit -n 16384 || true ulimit -n 16384 || true
if [[ "$MATRIX_SUITE" == "core" ]]; then if [[ "$MATRIX_SUITE" == "core" ]]; then
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
npx playwright test --grep @smoke --project="$MATRIX_PROJECT" npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT" npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
else else
@@ -508,6 +511,31 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 1 retention-days: 1
required-ci:
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
name: Required CI
needs: [format-tests, build-and-unit, e2e-pr]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify required jobs succeeded
shell: bash
env:
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
BUILD_AND_UNIT_RESULT: ${{ needs.build-and-unit.result }}
E2E_PR_RESULT: ${{ needs.e2e-pr.result }}
run: |
set -euo pipefail
failed=0
for required_job in FORMAT_TESTS_RESULT BUILD_AND_UNIT_RESULT E2E_PR_RESULT; do
result="${!required_job:-missing}"
if [[ "$result" != "success" ]]; then
echo "${required_job}=${result}" >&2
failed=1
fi
done
exit "$failed"
e2e-full: e2e-full:
if: > if: >
always() && always() &&
+1
View File
@@ -15,6 +15,7 @@ dist-ssr
coverage coverage
*.local *.local
dev-dist dev-dist
.playwright-cli/
# Mobile build and signing outputs # Mobile build and signing outputs
/app/build/ /app/build/
+50
View File
@@ -16,6 +16,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
npm install npm install
``` ```
## Contributing Changes
Create a scoped feature branch, push it, and open a pull request targeting
`master`. Do not push directly to `master`. Merge only after the `Required CI`
check succeeds, all review conversations are resolved, and the branch is up to
date. Use squash merge so `master` retains linear history.
See [`.github/BRANCH_PROTECTION.md`](.github/BRANCH_PROTECTION.md) for the
repository policy, rollout checks, and emergency bypass procedure.
### Compile and Hot-Reload for Development ### Compile and Hot-Reload for Development
```sh ```sh
@@ -154,6 +164,46 @@ Artifacts and summaries:
- `output/playwright/test-lists/<project>-<role>.txt` - `output/playwright/test-lists/<project>-<role>.txt`
- `output/playwright/test-lists/<role>-<project>.txt` (legacy compatibility copy) - `output/playwright/test-lists/<role>-<project>.txt` (legacy compatibility copy)
## Android App Icon
The Play Store Android package is built from the Capacitor project in `android/`.
The legacy Bubblewrap/TWA project at the repository root is not used by
`npm run mobile:android:bundle`.
The source image for the native launcher icon is:
```text
public/favicons/web-app-manifest-512x512.png
```
Regenerate the checked-in launcher assets after changing that source image:
```sh
npm run mobile:android:icons
```
Check that the generated Android launcher assets are current:
```sh
npm run mobile:android:icons:check
```
`npm run mobile:android:sync` runs the icon generator before building and syncing
the Capacitor Android project. The generator updates `android/app/src/main/res`
launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`,
and `store_icon.png`.
## Mobile Store Releases
Signed Android and iOS store artifacts are built through the GitHub Actions
`Mobile Store Artifacts` workflow. By default, current `master` after green
`Automated Tests` uploads Android to Google Play production and uploads iOS to
App Store Connect.
See `docs/mobile-artifacts.md` for workflow triggers, required secrets, and
local mobile checks. See `docs/app-store-release.md` for App Store Connect
release preparation and review notes.
## Bubblewrap (TWA) Build and Install ## Bubblewrap (TWA) Build and Install
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands: To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#0787BB</color>
</resources> </resources>
+102
View File
@@ -0,0 +1,102 @@
# Apple App Store Release Runbook
This runbook covers the public iOS App Store release path for the Truck Wash
Capacitor app.
## Account And App Record
- Use the Truck Wash ApS Apple Developer account. The Account Holder must accept
the latest Apple agreements before builds can be uploaded.
- Create or verify the App Store Connect app record:
- Platform: iOS
- Name: Truck Wash Kundeportal
- Bundle ID: `io.truckwash.app`
- SKU: `truckwash-ios`
- Primary language: Danish
- Category: Business
- Price: Free
- Initial availability: Denmark
- Keep the GitHub environment `mobile-store-production` configured with the
iOS signing, App Store Connect, Android signing, and Google Play upload
secrets used by the mobile workflow.
## Build And Upload
1. Merge the release commit to `master`.
2. Confirm `Automated Tests` and `Frontend Release` are green for that commit.
3. Create a release tag such as `mobile-v1.0.0`.
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts from
the tested commit. By default it uploads Android to the Google Play
production track and uploads the iOS IPA to App Store Connect.
5. For a manual upload, dispatch `Mobile Store Artifacts` with `version_name`
and `version_code`. Leave `upload_ios_to_app_store` enabled for the iOS
upload, or disable it to produce only the signed GitHub artifact.
The same workflow also runs automatically after a successful `Automated Tests`
run on current `master`. It skips stale workflow-run commits if `master` has
advanced before the mobile jobs start.
The iOS workflow expects these environment secrets:
- `IOS_CERTIFICATE_BASE64`
- `IOS_CERTIFICATE_PASSWORD`
- `IOS_PROVISION_PROFILE_BASE64`
- `IOS_KEYCHAIN_PASSWORD`
- `APPLE_TEAM_ID`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The workflow installs the signing certificate and provisioning profile in a
temporary keychain on the `macos-15` runner, archives the Capacitor Xcode
project, exports an App Store IPA, validates it with `xcrun altool`, uploads it
with the App Store Connect API key, and removes temporary signing assets in the
cleanup step.
## Product Page Defaults
- Support URL: `https://truckwash.io/support`
- Privacy URL: `https://truckwash.io/privacy-policy`
- Subtitle: `Book og start truckvask`
- Promotional text: `Administrer vask, koeretoejer, ordrer og fakturaer fra mobilen.`
- Keywords: `truck wash,lastbilvask,vask,booking,kundeportal`
- Expected age rating: 4+, subject to the App Store Connect questionnaire.
Use real iOS simulator or device screenshots. Provide at least:
- iPhone 6.9-inch portrait screenshots
- iPad 13-inch portrait screenshots
Recommended screenshot scenes: dashboard, booking flow, self-service wash start,
vehicles/orders, and invoices/payment history. Do not include real customer
data, private tokens, or placeholder copy.
## Privacy And Review Notes
App Store Connect privacy labels must match the actual app and backend behavior.
Expected minimum disclosures include account/contact data, identifiers such as
customer number, vehicle/license plate data, order and invoice history, payment
state, approximate/precise location when used, and photos or attachments when
users upload them. Tracking should remain false unless analytics/ad tracking is
introduced.
Review notes must include:
- A demo account and password.
- OTP/2FA/passkey fallback instructions when enabled for the account.
- A clear statement that Stripe/card payments are for physical truck-wash
services consumed outside the app, so Apple in-app purchase is not used.
- Any hardware-dependent functionality that reviewers cannot reproduce, with a
short demo video if needed.
- Confirmation that the backend environment is online for the whole review
window.
## TestFlight And Release
1. Wait for App Store Connect processing to finish.
2. Distribute the processed build to internal TestFlight testers.
3. Run clean-device QA on iPhone and iPad.
4. Fix issues using the same marketing version and an incremented build number.
5. Submit for App Review with manual release after approval.
6. After approval, release to Denmark first and monitor crashes, support mail,
and App Store Connect feedback before expanding availability.
+60
View File
@@ -0,0 +1,60 @@
# Customer attributes refactor and migration plan
## Problem statement
Customer attributes are currently represented as loosely typed string flags and evaluated in several UI, POS, and invoicing paths. This makes product restrictions vulnerable to broad category heuristics. The immediate defect is that `restrictAdditionalServices` ("Begræns tillægsydelser") treats related booking add-ons as additional services, so interior wash add-ons plus trailer/dolly additions are blocked even though that attribute is intended to cover standalone additional services only.
## Target behavior matrix
| Attribute | Canonical intent | Product availability behavior | Invoice/workflow behavior |
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `restrictAdditionalServices` | Block standalone additional services/tillægsydelser. | Block standalone additional-service catalog items; do not block related booking add-ons such as interior wash, trailer, or dolly. | Flag only order lines that are standalone additional services. |
| `restrictTankCleaning` | Block tank-cleaning services. | Block products whose category or legacy name identifies tank cleaning. | Flag tank-cleaning order lines. |
| `restrictSpotFree` | Block Spot Free/RO rinse products. | Block canonical Spot Free product IDs and legacy Spot Free/RO naming. | Flag Spot Free order lines. |
| `restrictInteriorCleaning` | Block interior wash services. | Block products whose names/categories explicitly identify interior wash. | Flag interior-wash order lines. |
| `onlyTankCleaning` | Allow only tank-cleaning services. | Block every non-tank-cleaning product while keeping tank-cleaning products available. | Flag non-tank-cleaning order lines. |
| `requiresReferenceNumber` | Require an order reference. | No product filtering. | Flag orders missing a required reference. |
| `requiresRegistrationNumbersInvoice` | Require registration numbers on invoice/order context. | No product filtering. | Flag orders missing required registration numbers. |
| `invoiceAllOrdersIndividually` | Prevent grouped invoicing. | No product filtering. | Split/flag invoice collections containing multiple orders for the customer. |
| `invoiceWithStripe` | Invoice through Stripe workflow. | No product filtering. | Route the customer through Stripe invoicing/payment handling. |
| `showPricesOnBookingPage` | Show customer prices during booking. | No product filtering. | Presentation-only booking behavior. |
| `usePONumbers` | Use/prompt for PO numbers. | No product filtering. | Require or expose PO-number workflow where configured. |
| `exemptFromAdministrationFee` | Do not charge administration fees. | No product filtering. | Suppress/flag administration-fee order lines for this customer. |
## Refactor plan
1. **Create a canonical customer-rule domain module**
- Keep `CUSTOMER_RULE_DEFINITIONS` as the registry of public attributes, but extend each entry with a typed evaluator contract: product predicate, category predicate, invoice predicate, and UI impact metadata.
- Replace scattered string comparisons with registry lookups so every surface uses the same semantics.
- Add explicit names for ambiguous categories: `standaloneAdditionalService`, `relatedAddon`, `primaryProduct`, `tankCleaning`, `spotFree`, and `interiorCleaning`.
2. **Normalize product classification once**
- Build a `classifyCustomerRuleProduct(product, context)` helper returning booleans for each product class.
- Treat related add-ons (`isRelatedAddon`, `relatedItemId`) as context, not as proof that the item is a standalone additional service.
- Reserve `restrictAdditionalServices` for category 8/standalone service context or explicit additional-service labels, not numeric booking add-on category 4.
3. **Migrate rule evaluation paths**
- POS product cards and mobile flows should call `getCustomerProductRestriction` only with the normalized product context.
- Customer-rule tooltips should derive blocked/available products from the same evaluator used by POS.
- Invoicing-period flag generation should use the same classification vocabulary as product availability so historical and current orders are flagged consistently.
4. **Backfill and data migration**
- Keep existing attribute keys unchanged to avoid a destructive migration.
- Add a one-time data audit/report listing customers with `restrictAdditionalServices` and recent orders containing interior wash, trailer, or dolly add-ons. These rows should be verified as no longer violating the rule after deployment.
- If any historical invoice flags were created solely because related add-ons were treated as additional services, provide an idempotent cleanup command to recalculate customer-rule violations for affected invoice periods.
5. **Regression test coverage**
- Unit-test every attribute in the target behavior matrix.
- Add focused cases for the defect: interior wash related add-on, trailer related add-on, and dolly related add-on must remain available under `restrictAdditionalServices`.
- Add invoice-flag fixtures mirroring the same products so invoicing behavior cannot drift from POS behavior.
- Keep tooltip tests aligned with the evaluator, showing standalone additional services under `restrictAdditionalServices` and not showing related add-ons.
6. **Rollout and verification**
- Ship the evaluator patch behind the existing attribute keys.
- Run unit tests and targeted POS/customer-rule e2e tests.
- Verify with production-like catalog data that `restrictAdditionalServices` blocks only standalone additional services while `restrictInteriorCleaning`, `restrictTankCleaning`, `restrictSpotFree`, and `onlyTankCleaning` continue to behave exactly as listed above.
+41 -3
View File
@@ -1,23 +1,41 @@
# Mobile Store Artifacts # Mobile Store Artifacts
The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor. The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor, then uploads them to Google Play and App Store Connect by default.
Use the Capacitor project under `android/` for the Google Play Store package. The Bubblewrap/TWA files at the repository root are not the path used by `mobile:android:bundle`. Use the Capacitor project under `android/` for the Google Play Store package. The Bubblewrap/TWA files at the repository root are not the path used by `mobile:android:bundle`.
## Triggers ## Triggers
- Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name` and `version_code`. - Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name`, `version_code`, upload toggles, and Android track/status overrides.
- Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name. - Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name.
- Automatic Android Play Store artifact: after the `Automated Tests` workflow completes successfully on `master`, GitHub Actions builds and uploads a signed Android App Bundle from the tested commit. - Automatic store upload: after the `Automated Tests` workflow completes successfully on current `master`, GitHub Actions builds signed Android and iOS artifacts from that tested commit and uploads them to the stores.
- Stale workflow-run protection: if a newer commit reaches `master` before the mobile workflow runs, both store-upload jobs skip the stale commit.
Default upload behavior:
- Android uploads package `io.truckwash.twa` to the Google Play `production` track with release status `completed`.
- iOS uploads bundle `io.truckwash.app` to App Store Connect for TestFlight/App Review processing. Public App Store release still depends on App Store Connect review and release settings.
- Manual dispatch can disable either upload path while still producing signed GitHub artifacts.
## Required Secrets ## Required Secrets
Store secrets are expected in the GitHub environment `mobile-store-production`.
Non-secret environment variables:
- `ANDROID_PACKAGE_NAME=io.truckwash.twa`
- `ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab`
- `PLAY_STORE_TRACK=production`
- `PLAY_STORE_RELEASE_STATUS=completed`
- `PLAY_STORE_USER_FRACTION` only when using `PLAY_STORE_RELEASE_STATUS=inProgress`
Android: Android:
- `ANDROID_KEYSTORE_BASE64` - `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD` - `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS` - `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD` - `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
iOS: iOS:
@@ -26,6 +44,11 @@ iOS:
- `IOS_PROVISION_PROFILE_BASE64` - `IOS_PROVISION_PROFILE_BASE64`
- `IOS_KEYCHAIN_PASSWORD` - `IOS_KEYCHAIN_PASSWORD`
- `APPLE_TEAM_ID` - `APPLE_TEAM_ID`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The Google Play secret is a base64-encoded service-account JSON file with Android Publisher API access to the Play Console app. The App Store Connect private key secret is the base64-encoded `.p8` API key file.
## Local Checks ## Local Checks
@@ -57,6 +80,21 @@ The signed Android bundle is written to:
android/app/build/outputs/bundle/release/app-release.aab android/app/build/outputs/bundle/release/app-release.aab
``` ```
Upload a locally built signed App Bundle to Google Play after exporting the Play service-account secret and release metadata:
```sh
export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64=...
export ANDROID_PACKAGE_NAME=io.truckwash.twa
export ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab
export MOBILE_VERSION_NAME=1.4.0
export MOBILE_VERSION_CODE=10400
export PLAY_STORE_TRACK=production
export PLAY_STORE_RELEASE_STATUS=completed
npm run mobile:android:play-upload
```
Use `PLAY_STORE_RELEASE_STATUS=inProgress` only with `PLAY_STORE_USER_FRACTION` set to a value greater than `0` and less than `1`.
Android artifacts use package id `io.truckwash.twa`. iOS artifacts use bundle id `io.truckwash.app`. Android artifacts use package id `io.truckwash.twa`. iOS artifacts use bundle id `io.truckwash.app`.
The Android project currently targets SDK 36. Google Play requires new apps and updates to target Android 15/API 35 or higher starting August 31, 2025: https://developer.android.com/google/play/requirements/target-sdk The Android project currently targets SDK 36. Google Play requires new apps and updates to target Android 15/API 35 or higher starting August 31, 2025: https://developer.android.com/google/play/requirements/target-sdk
+434 -3
View File
@@ -1,9 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="" class="theme-light" data-theme="light"> <html lang="" class="theme-light tw-bootstrap-loading" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script> <script>
(function () { (function () {
window.__TW_LOADER_STARTED_AT__ = window.performance && typeof window.performance.now === 'function'
? window.performance.now()
: Date.now();
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/); var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/'; var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base'); var base = document.createElement('base');
@@ -21,9 +24,437 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>Truck Wash Kundeportal</title> <title>Truck Wash Kundeportal</title>
<style>
html,
body,
#app {
min-height: 100%;
}
html.tw-bootstrap-loading {
font-size: 16px !important;
}
body {
margin: 0;
background: #fff;
}
body.tw-bootstrap-loading {
background: #041f32;
}
html.tw-bootstrap-loading,
html.tw-loader-scroll-lock,
body.tw-bootstrap-loading,
body.tw-loader-scroll-lock {
overflow: hidden;
scrollbar-width: none;
}
html.tw-bootstrap-loading::-webkit-scrollbar,
html.tw-loader-scroll-lock::-webkit-scrollbar,
body.tw-bootstrap-loading::-webkit-scrollbar,
body.tw-loader-scroll-lock::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.tw-bootstrap-loader {
--tw-loader-navy: #063651;
--tw-loader-blue: #0787bb;
--tw-loader-cyan: #69d7f5;
--tw-loader-foam: #f8fbff;
--tw-loader-warm: #f4c15d;
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 2rem;
box-sizing: border-box;
background:
radial-gradient(circle at 20% 20%, rgba(105, 215, 245, 0.18), transparent 28rem),
radial-gradient(circle at 82% 74%, rgba(244, 193, 93, 0.12), transparent 24rem),
linear-gradient(145deg, #041f32 0%, var(--tw-loader-navy) 52%, #04263d 100%);
color: var(--tw-loader-foam);
font-family: Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.tw-bootstrap-loader__ambient {
position: absolute;
inset: 0;
pointer-events: none;
}
.tw-bootstrap-loader__ambient::before {
position: absolute;
inset: 14% 9%;
content: "";
border: 1px solid rgba(248, 251, 255, 0.08);
border-radius: 8px;
}
.tw-bootstrap-loader__wash {
position: absolute;
left: 50%;
display: block;
width: 72rem;
height: 8rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(105, 215, 245, 0.2), rgba(248, 251, 255, 0.18), transparent);
filter: blur(8px);
transform: translateX(-50%) rotate(-9deg);
}
.tw-bootstrap-loader__wash--wide {
top: 26%;
animation: tw-bootstrap-loader-wash 5.8s ease-in-out var(--tw-loader-wash-wide-delay, 0ms) infinite;
}
.tw-bootstrap-loader__wash--tight {
bottom: 22%;
width: 48rem;
height: 5rem;
animation: tw-bootstrap-loader-wash 6.8s ease-in-out var(--tw-loader-wash-tight-delay, 0ms) infinite reverse;
}
.tw-bootstrap-loader__panel {
position: relative;
display: grid;
width: min(100%, 25rem);
min-height: 27rem;
align-content: center;
justify-items: center;
padding: 2.5rem 2rem 2.25rem;
box-sizing: border-box;
border: 1px solid rgba(248, 251, 255, 0.16);
border-radius: 8px;
background: rgba(4, 31, 50, 0.74);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
text-align: center;
backdrop-filter: blur(18px);
}
.tw-bootstrap-loader__panel::after {
position: absolute;
inset: 0;
content: "";
border-radius: inherit;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.08), transparent 38%);
pointer-events: none;
}
.tw-bootstrap-loader__brand,
.tw-bootstrap-loader__motion,
.tw-bootstrap-loader__status,
.tw-bootstrap-loader__hint,
.tw-bootstrap-loader__dots {
position: relative;
z-index: 1;
}
.tw-bootstrap-loader__brand {
display: grid;
width: 13rem;
min-height: 4rem;
place-items: center;
margin-bottom: 2.25rem;
}
.tw-bootstrap-loader__logo {
display: block;
width: 100%;
max-height: 4rem;
object-fit: contain;
}
.tw-bootstrap-loader__motion {
display: grid;
width: 8.5rem;
height: 8.5rem;
place-items: center;
margin-bottom: 2rem;
}
.tw-bootstrap-loader__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(248, 251, 255, 0.12);
border-top-color: var(--tw-loader-cyan);
border-right-color: rgba(7, 135, 187, 0.78);
border-radius: 50%;
animation: tw-bootstrap-loader-spin 1.8s linear var(--tw-loader-spin-delay, 0ms) infinite;
}
.tw-bootstrap-loader__ring::before,
.tw-bootstrap-loader__ring::after {
position: absolute;
content: "";
border-radius: 50%;
}
.tw-bootstrap-loader__ring::before {
inset: 1rem;
border: 1px solid rgba(248, 251, 255, 0.18);
}
.tw-bootstrap-loader__ring::after {
inset: 2.3rem;
background: radial-gradient(circle, rgba(105, 215, 245, 0.38), rgba(7, 135, 187, 0.18) 45%, transparent 70%);
animation: tw-bootstrap-loader-pulse 2.4s ease-in-out var(--tw-loader-pulse-delay, 0ms) infinite;
}
.tw-bootstrap-loader__sweep {
width: 6rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent 0%, rgba(248, 251, 255, 0.92) 45%, var(--tw-loader-cyan) 100%);
box-shadow: 0 0 24px rgba(105, 215, 245, 0.48);
transform: rotate(-12deg);
animation: tw-bootstrap-loader-sweep 1.8s ease-in-out var(--tw-loader-sweep-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--tw-loader-warm);
box-shadow: 0 0 18px rgba(244, 193, 93, 0.76);
opacity: 0.9;
}
.tw-bootstrap-loader__spark--one {
top: 1rem;
right: 1.3rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark--two {
bottom: 1.4rem;
left: 1rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-two-delay, 0.65s) infinite;
}
.tw-bootstrap-loader__status {
margin: 0;
color: var(--tw-loader-foam);
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
}
.tw-bootstrap-loader__hint {
max-width: 18rem;
margin: 0.55rem 0 0;
color: rgba(248, 251, 255, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.tw-bootstrap-loader__dots {
display: inline-flex;
gap: 0.45rem;
height: 0.6rem;
margin-top: 1.35rem;
align-items: center;
}
.tw-bootstrap-loader__dots span {
display: block;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--tw-loader-cyan);
animation: tw-bootstrap-loader-dot 1.35s ease-in-out var(--tw-loader-dot-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__dots span:nth-child(2) {
animation-delay: var(--tw-loader-dot-two-delay, 0.18s);
}
.tw-bootstrap-loader__dots span:nth-child(3) {
animation-delay: var(--tw-loader-dot-three-delay, 0.36s);
}
@keyframes tw-bootstrap-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes tw-bootstrap-loader-sweep {
0%,
100% {
opacity: 0.6;
transform: translateX(-0.65rem) rotate(-12deg);
}
50% {
opacity: 1;
transform: translateX(0.65rem) rotate(-12deg);
}
}
@keyframes tw-bootstrap-loader-pulse {
0%,
100% {
opacity: 0.7;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-spark {
0%,
100% {
opacity: 0.35;
transform: scale(0.7);
}
45% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-dot {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
45% {
opacity: 1;
transform: translateY(-0.25rem);
}
}
@keyframes tw-bootstrap-loader-wash {
0%,
100% {
opacity: 0.42;
transform: translateX(-54%) rotate(-9deg);
}
50% {
opacity: 0.72;
transform: translateX(-46%) rotate(-9deg);
}
}
@media screen and (max-width: 480px) {
.tw-bootstrap-loader {
padding: 1rem;
}
.tw-bootstrap-loader__panel {
min-height: 25rem;
padding: 2rem 1.4rem;
}
.tw-bootstrap-loader__brand {
width: 11.5rem;
margin-bottom: 1.8rem;
}
.tw-bootstrap-loader__motion {
width: 7.25rem;
height: 7.25rem;
margin-bottom: 1.75rem;
}
.tw-bootstrap-loader__status {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tw-bootstrap-loader *,
.tw-bootstrap-loader *::before,
.tw-bootstrap-loader *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.tw-bootstrap-loader__sweep {
opacity: 0.95;
transform: rotate(-12deg);
}
.tw-bootstrap-loader__dots span {
opacity: 0.75;
transform: none;
}
}
</style>
</head> </head>
<body> <body class="tw-bootstrap-loading">
<div id="app"></div> <div id="app">
<div
class="tw-bootstrap-loader"
data-testid="app-bootstrap-loading"
role="status"
aria-live="polite"
aria-label="Indlæser Truck Wash kundeportal"
>
<div class="tw-bootstrap-loader__ambient" aria-hidden="true">
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--wide"></span>
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--tight"></span>
</div>
<div class="tw-bootstrap-loader__panel">
<div class="tw-bootstrap-loader__brand">
<img
src="%BASE_URL%assets/branding/truckwash-banner-white-compressed.png"
alt="Truck Wash"
class="tw-bootstrap-loader__logo"
/>
</div>
<div class="tw-bootstrap-loader__motion" aria-hidden="true">
<span class="tw-bootstrap-loader__ring"></span>
<span class="tw-bootstrap-loader__sweep"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--one"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--two"></span>
</div>
<p class="tw-bootstrap-loader__status" data-testid="app-bootstrap-loading-status">Indlæser...</p>
<p class="tw-bootstrap-loader__hint">Vi gør kundeportalen klar</p>
<div class="tw-bootstrap-loader__dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<script>
(function () {
var appPath = window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, '') || '/';
var hasStoredSession = false;
try {
hasStoredSession = Boolean(window.localStorage && window.localStorage.getItem('token'));
} catch (error) {
hasStoredSession = false;
}
var usesProtectedSessionLoader = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/.test(appPath);
var usesGuestSessionLoader = /^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/.test(appPath);
var usesSessionLoader = usesProtectedSessionLoader || (hasStoredSession && usesGuestSessionLoader);
if (!usesSessionLoader) return;
var loader = document.querySelector('[data-testid="app-bootstrap-loading"]');
var status = document.querySelector('[data-testid="app-bootstrap-loading-status"]');
var hint = loader ? loader.querySelector('.tw-bootstrap-loader__hint') : null;
if (loader) loader.setAttribute('aria-label', 'Bekræfter bruger');
if (status) status.textContent = 'Bekræfter bruger...';
if (hint) hint.textContent = 'Vi indlæser din session';
})();
</script>
<script type="module" src="/src/releaseBootstrap.js"></script> <script type="module" src="/src/releaseBootstrap.js"></script>
</body> </body>
</html> </html>
+225
View File
@@ -70,6 +70,7 @@
"@creativebulma/bulma-divider": "^1.1.0", "@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0", "@event-calendar/core": "^4.1.0",
"@playwright/experimental-ct-vue": "^1.58.2",
"@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",
@@ -79,6 +80,7 @@
"eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"prettier": "2.8.8", "prettier": "2.8.8",
@@ -4218,6 +4220,229 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/experimental-ct-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.58.2.tgz",
"integrity": "sha512-Imif9ggQp6YIblHAX6MvJuqDFrCGHYspoibxLP3+1soXp+1wBNuuSRajv0VWXzFbh//4l29I+xy2tpTgej0vEA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2",
"playwright-core": "1.58.2",
"vite": "^6.4.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/experimental-ct-core/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/experimental-ct-core/node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/@playwright/experimental-ct-vue": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-vue/-/experimental-ct-vue-1.58.2.tgz",
"integrity": "sha512-joLgyXZOV7odSY3PrEkzNRqz6rdaf7eoyTus4deSAAD6u5qvWYgDZl2mDJYiivcE+jDns9SGiZQlkMmyDcjQpg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.58.2",
"@vitejs/plugin-vue": "^5.2.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/experimental-ct-vue/node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@playwright/experimental-ct-vue/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/experimental-ct-vue/node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.58.2", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+10 -2
View File
@@ -47,19 +47,25 @@
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live", "test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live", "test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live", "test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
"release:verify-upload": "node scripts/release/verify-upload.mjs", "release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs", "release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh", "release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"", "test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
"test:all": "npm run test:unit && npm run test:e2e:pr", "test:all": "npm run test:unit && npm run test:ct:pr && npm run test:e2e:pr",
"twa:build": "bubblewrap build", "twa:build": "bubblewrap build",
"twa:update": "bubblewrap update", "twa:update": "bubblewrap update",
"mobile:sync": "npm run build && npx cap sync", "mobile:sync": "npm run build && npx cap sync",
"mobile:android:sync": "npm run build && npx cap sync android", "mobile:android:icons": "node scripts/mobile/generate-android-icons.mjs",
"mobile:android:icons:check": "node scripts/mobile/generate-android-icons.mjs --check",
"mobile:android:sync": "npm run mobile:android:icons && npm run build && npx cap sync android",
"mobile:permissions:check": "node scripts/mobile/check-permissions.mjs", "mobile:permissions:check": "node scripts/mobile/check-permissions.mjs",
"mobile:store:env-check": "node scripts/mobile/check-store-upload-env.mjs",
"mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs", "mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs",
"mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease", "mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease", "mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check", "mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs" "playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
}, },
@@ -126,6 +132,7 @@
"@creativebulma/bulma-divider": "^1.1.0", "@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0", "@event-calendar/core": "^4.1.0",
"@playwright/experimental-ct-vue": "^1.58.2",
"@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",
@@ -135,6 +142,7 @@
"eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"prettier": "2.8.8", "prettier": "2.8.8",
+1 -1
View File
@@ -40,7 +40,7 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/release/**"], testIgnore: ["**/release/**", "**/quarantine/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}", snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000, timeout: 60_000,
fullyParallel: true, fullyParallel: true,
+53
View File
@@ -0,0 +1,53 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, devices } from "@playwright/experimental-ct-vue";
const projectRoot = fileURLToPath(new URL(".", import.meta.url));
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "ct").trim();
const artifactRoot = path.join("output", "playwright", artifactNamespace);
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [["line"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]]
: [["list"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]];
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
export default defineConfig({
testDir: "./tests/ct",
timeout: 45_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers,
reporter,
outputDir: path.join(artifactRoot, "test-results"),
use: {
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
ctViteConfig: {
resolve: {
alias: {
"@": path.resolve(projectRoot, "src"),
},
preserveSymlinks: true,
dedupe: ["vue", "vue-router", "vue-i18n", "@vueuse/core", "@vueuse/head", "@unhead/vue"],
},
},
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
],
});
+57
View File
@@ -0,0 +1,57 @@
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.truckwash.twa",
"sha256_cert_fingerprints": [
"29:56:F7:8D:BD:A0:2E:A9:32:82:97:28:A3:E2:65:16:23:73:DD:2C:16:F6:7A:97:AD:63:27:14:5C:8C:FB:89"
]
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
<script type="module" crossorigin src="/assets/index-6ljr7rTu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq5akw1E.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#0787bb",
"theme_color": "#063651",
"orientation": "portrait",
"scope": "/"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
import { beforeMount } from "@playwright/experimental-ct-vue/hooks";
import Buefy from "buefy";
import "bulma/css/bulma.min.css";
import "buefy/dist/css/buefy.css";
import i18n from "@/i18n";
type PlenoHooksConfig = {
locale?: string;
};
beforeMount(({ app, hooksConfig }) => {
const config = (hooksConfig || {}) as PlenoHooksConfig;
const locale = config.locale || "en";
const globalLocale = i18n.global.locale as unknown;
if (globalLocale && typeof globalLocale === "object" && "value" in globalLocale) {
(globalLocale as { value: string }).value = locale;
} else {
(i18n.global as unknown as { locale: string }).locale = locale;
}
app.use(i18n);
app.use(Buefy);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+18 -1
View File
@@ -275,7 +275,24 @@ const transformLocale = (locale, originalMessages) => {
(entry) => normalizeToken(entry.value) === normalized (entry) => normalizeToken(entry.value) === normalized
); );
if (existingWordEntry) { if (existingWordEntry) {
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value }); const word = {
path: `words.${existingWordEntry.key}`,
value: existingWordEntry.value,
variants: new Map(),
};
const casings = [...stat.casings.keys()].sort(compareStringValues);
for (const casing of casings) {
if (modifierForToken(casing, word) !== null) {
continue;
}
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
generatedWords[variantKey] = casing;
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
}
tokenToWord.set(normalized, word);
continue; continue;
} }
+148
View File
@@ -0,0 +1,148 @@
import { existsSync } from "node:fs";
import { argv, env, exit } from "node:process";
const args = new Set(argv.slice(2));
const failures = [];
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const requireOneVariable = (names, label) => {
if (!names.some((name) => env[name])) {
failures.push(`Missing ${label}: set one of ${names.join(", ")}`);
}
};
const decodeBase64 = (name) => {
if (!env[name]) {
return null;
}
try {
const decoded = Buffer.from(env[name], "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
return decoded;
} catch {
failures.push(`${name} is not valid base64`);
return null;
}
};
const decodeBase64Json = (name) => {
const decoded = decodeBase64(name);
if (!decoded) {
return null;
}
try {
return JSON.parse(decoded.toString("utf8"));
} catch {
failures.push(`${name} is not base64-encoded JSON`);
return null;
}
};
const isEnabled = (name) => !["false", "0", "no"].includes(String(env[name] ?? "true").toLowerCase());
const checkAndroid = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireOneVariable(["ANDROID_KEYSTORE_BASE64", "ANDROID_KEYSTORE_FILE"], "Android release keystore");
requireVariable("ANDROID_KEYSTORE_PASSWORD");
requireVariable("ANDROID_KEY_ALIAS");
requireVariable("ANDROID_KEY_PASSWORD");
if (env.ANDROID_KEYSTORE_FILE && !existsSync(env.ANDROID_KEYSTORE_FILE)) {
failures.push("ANDROID_KEYSTORE_FILE does not point to an existing file");
}
decodeBase64("ANDROID_KEYSTORE_BASE64");
if (!isEnabled("UPLOAD_ANDROID_TO_PLAY")) {
return;
}
requireVariable("ANDROID_PACKAGE_NAME");
requireVariable("ANDROID_AAB_PATH");
requireVariable("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
}
if (!validStatuses.has(status)) {
failures.push(`PLAY_STORE_RELEASE_STATUS must be one of ${Array.from(validStatuses).join(", ")}`);
}
if (status === "inProgress") {
const fraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(fraction > 0 && fraction < 1)) {
failures.push("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress");
}
}
const serviceAccount = decodeBase64Json("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
if (serviceAccount) {
if (!serviceAccount.client_email) {
failures.push("Google Play service account JSON is missing client_email");
}
if (!serviceAccount.private_key) {
failures.push("Google Play service account JSON is missing private_key");
}
}
};
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_CERTIFICATE_BASE64");
requireVariable("IOS_CERTIFICATE_PASSWORD");
requireVariable("IOS_PROVISION_PROFILE_BASE64");
requireVariable("IOS_KEYCHAIN_PASSWORD");
requireVariable("APPLE_TEAM_ID");
decodeBase64("IOS_CERTIFICATE_BASE64");
decodeBase64("IOS_PROVISION_PROFILE_BASE64");
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
return;
}
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
requireVariable("APP_STORE_CONNECT_ISSUER_ID");
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
if (privateKey && !privateKey.toString("utf8").includes("PRIVATE KEY")) {
failures.push("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 does not look like a .p8 private key");
}
};
if (args.has("--android")) {
checkAndroid();
}
if (args.has("--ios")) {
checkIos();
}
if (!args.has("--android") && !args.has("--ios")) {
failures.push("Pass --android or --ios");
}
if (failures.length > 0) {
console.error("Mobile store upload environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("Mobile store upload environment is configured.");
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const checkOnly = process.argv.includes("--check");
const launcherBackground = "#0787BB";
const densityScale = {
mdpi: 1,
hdpi: 1.5,
xhdpi: 2,
xxhdpi: 3,
xxxhdpi: 4,
};
const targets = [
{ relativePath: "public/icons/icon-512x512.png", size: 512, copySource: true },
{ relativePath: "public/icons/icon-192x192.png", size: 192 },
{ relativePath: "store_icon.png", size: 512, copySource: true },
];
for (const [density, scale] of Object.entries(densityScale)) {
const legacySize = Math.round(48 * scale);
const foregroundSize = Math.round(108 * scale);
targets.push(
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_round.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
size: foregroundSize,
},
);
}
const xmlTargets = [
{
relativePath: "android/app/src/main/res/values/ic_launcher_background.xml",
content: `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${launcherBackground}</color>\n</resources>\n`,
},
];
function targetPath(relativePath) {
return path.join(projectRoot, relativePath);
}
async function readIfExists(filePath) {
try {
return await fs.readFile(filePath);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
async function writeIfChanged(relativePath, content) {
const filePath = targetPath(relativePath);
const current = await readIfExists(filePath);
if (current?.equals(content)) {
return false;
}
if (checkOnly) {
return true;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
return true;
}
async function renderPng(sourceImage, size) {
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
return image.getBufferAsync(Jimp.MIME_PNG);
}
async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
}
const changed = [];
for (const target of targets) {
const buffer = target.copySource ? sourceBuffer : await renderPng(sourceImage, target.size);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
}
for (const target of xmlTargets) {
if (await writeIfChanged(target.relativePath, Buffer.from(target.content))) {
changed.push(target.relativePath);
}
}
if (changed.length === 0) {
console.log(`Android icon assets are current (${targets.length + xmlTargets.length} files).`);
return;
}
if (checkOnly) {
console.error("Android icon assets are out of date:");
for (const relativePath of changed) {
console.error(`- ${relativePath}`);
}
console.error("Run `npm run mobile:android:icons`.");
process.exitCode = 1;
return;
}
console.log(`Updated ${changed.length} Android icon asset${changed.length === 1 ? "" : "s"}.`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+260
View File
@@ -0,0 +1,260 @@
import { createSign } from "node:crypto";
import { existsSync, readFileSync, appendFileSync } from "node:fs";
import { env, exit } from "node:process";
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
const requiredVariables = [
"ANDROID_PACKAGE_NAME",
"ANDROID_AAB_PATH",
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
];
const fail = (message) => {
console.error(message);
exit(1);
};
const requireEnvironment = () => {
const missingVariables = requiredVariables.filter((name) => !env[name]);
if (missingVariables.length > 0) {
fail(`Google Play upload is not configured. Missing: ${missingVariables.join(", ")}`);
}
if (!existsSync(env.ANDROID_AAB_PATH)) {
fail(`Android App Bundle not found at ${env.ANDROID_AAB_PATH}`);
}
};
const base64Url = (value) =>
Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const parseServiceAccount = () => {
let serviceAccount;
try {
serviceAccount = JSON.parse(Buffer.from(env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64, "base64").toString("utf8"));
} catch {
fail("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 must be base64-encoded service account JSON.");
}
if (!serviceAccount.client_email || !serviceAccount.private_key) {
fail("Google Play service account JSON must include client_email and private_key.");
}
return serviceAccount;
};
const createJwtAssertion = (serviceAccount) => {
const now = Math.floor(Date.now() / 1000);
const header = {
alg: "RS256",
typ: "JWT",
};
const claims = {
iss: serviceAccount.client_email,
scope: androidPublisherScope,
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
return `${signingInput}.${base64Url(signer.sign(serviceAccount.private_key))}`;
};
const readJsonResponse = async (response, label) => {
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text };
}
}
if (!response.ok) {
const detail = body?.error?.message || body?.raw || response.statusText;
throw new Error(`${label} failed with HTTP ${response.status}: ${detail}`);
}
return body;
};
const requestJson = async (url, options, label) => {
const response = await fetch(url, options);
return readJsonResponse(response, label);
};
const getAccessToken = async (serviceAccount) => {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: createJwtAssertion(serviceAccount),
});
const tokenResponse = await requestJson(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Google OAuth token request",
);
if (!tokenResponse?.access_token) {
throw new Error("Google OAuth token response did not include access_token.");
}
return tokenResponse.access_token;
};
const playUrl = (path) => `https://androidpublisher.googleapis.com/androidpublisher/v3/${path}`;
const playUploadUrl = (path) => `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/${path}`;
const authorizedJson = (accessToken, extraHeaders = {}) => ({
Authorization: `Bearer ${accessToken}`,
...extraHeaders,
});
const insertEdit = async (accessToken, packageName) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit insert",
);
const deleteEdit = async (accessToken, packageName, editId) => {
const response = await fetch(playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}`), {
method: "DELETE",
headers: authorizedJson(accessToken),
});
if (!response.ok && response.status !== 404) {
const body = await response.text();
console.warn(`Could not delete failed Google Play edit ${editId}: HTTP ${response.status} ${body}`);
}
};
const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
requestJson(
`${playUploadUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/bundles`,
)}?uploadType=media`,
{
method: "POST",
headers: authorizedJson(accessToken, {
"Content-Type": "application/octet-stream",
}),
body: readFileSync(bundlePath),
},
"Google Play bundle upload",
);
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
if (!validTracks.has(track)) {
throw new Error(`Unsupported PLAY_STORE_TRACK: ${track}`);
}
if (!validStatuses.has(status)) {
throw new Error(`Unsupported PLAY_STORE_RELEASE_STATUS: ${status}`);
}
const release = {
name: env.PLAY_STORE_RELEASE_NAME || `Truck Wash ${env.MOBILE_VERSION_NAME} (${versionCode})`,
versionCodes: [String(versionCode)],
status,
};
if (status === "inProgress") {
const userFraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(userFraction > 0 && userFraction < 1)) {
throw new Error("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress.");
}
release.userFraction = userFraction;
}
return requestJson(
playUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/tracks/${encodeURIComponent(track)}`,
),
{
method: "PUT",
headers: authorizedJson(accessToken, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
track,
releases: [release],
}),
},
"Google Play track update",
);
};
const commitEdit = async (accessToken, packageName, editId) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}:commit`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit commit",
);
const writeStepSummary = (summary) => {
if (!env.GITHUB_STEP_SUMMARY) {
return;
}
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
};
const main = async () => {
requireEnvironment();
const packageName = env.ANDROID_PACKAGE_NAME;
const serviceAccount = parseServiceAccount();
const accessToken = await getAccessToken(serviceAccount);
let editId = null;
try {
const edit = await insertEdit(accessToken, packageName);
editId = edit.id;
if (!editId) {
throw new Error("Google Play edit insert response did not include id.");
}
const bundle = await uploadBundle(accessToken, packageName, editId, env.ANDROID_AAB_PATH);
const versionCode = String(bundle?.versionCode || env.MOBILE_VERSION_CODE);
await updateTrack(accessToken, packageName, editId, versionCode);
await commitEdit(accessToken, packageName, editId);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
writeStepSummary(`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}.`);
} catch (error) {
if (editId) {
await deleteEdit(accessToken, packageName, editId);
}
throw error;
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
+74 -4
View File
@@ -30,14 +30,29 @@ export const sourceMappings = [
}, },
{ {
name: "limited-backoffice", name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\//u, /^src\/services\/limitedBackoffice\.js$/u], patterns: [
/^src\/views\/backoffice\//u,
/^src\/services\/limitedBackoffice\.js$/u,
/^src\/services\/departmentCustomerPricing\.js$/u,
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
],
specs: ["tests/e2e/limited-backoffice.spec.ts"], specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects, projects: chromiumProjects,
}, },
{
name: "superuser-department-employees",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentEmployees\.vue$/u,
/^src\/views\/backoffice\/components\/LimitedBackofficeEmployeesManager\.vue$/u,
],
specs: ["tests/e2e/superuser-department-employees.spec.ts"],
projects: ["chromium-desktop"],
},
{ {
name: "superuser-roles-permissions", name: "superuser-roles-permissions",
patterns: [ patterns: [
/^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u, /^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/roles\/RolePermissionManager\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u, /^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u,
], ],
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"], specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
@@ -65,7 +80,7 @@ export const sourceMappings = [
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u, /^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u, /^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
], ],
specs: ["tests/e2e/superuser-department-overview.spec.js"], specs: ["tests/e2e/superuser-department-overview.spec.js", "tests/e2e/superuser-department-employees.spec.ts"],
projects: chromiumProjects, projects: chromiumProjects,
}, },
{ {
@@ -93,6 +108,33 @@ export const sourceMappings = [
specs: ["tests/e2e/userVehicles.spec.ts"], specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects, projects: chromiumProjects,
}, },
{
name: "orders-filters",
patterns: [
/^src\/components\/displays\/pagination\/models\/(?:DepartmentPos\/OrdersPagination|SuperUserDashboard\/InvoiceOrdersPagination)\.vue$/u,
/^src\/components\/displays\/pagination\/(?:PaginationOtherFiltersDropdown|TableLabeledPagination)\.vue$/u,
/^src\/components\/displays\/buttons\/DatePeriodSelector\.vue$/u,
/^src\/services\/orderDateEvents\.js$/u,
/^src\/services\/relativeDateShortcuts\.js$/u,
/^src\/views\/dashboards\/departmentDashboard\/modules\/Pos\/DepartmentPos(?:Orders|Drafts)\.vue$/u,
],
specs: [
"tests/e2e/admin-pos-order-filters.spec.ts",
"tests/e2e/superuser-orders-date-filters.spec.ts",
],
projects: chromiumProjects,
},
{
name: "superuser-customer-rules",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/CustomerRuleProductRestrictions\.vue$/u,
/^src\/features\/customer\/customerRuleProductRestrictionService\.js$/u,
/^src\/features\/customer\/customerRuleConfigurationPermissions\.js$/u,
/^src\/views\/dashboards\/superUserDashboard\/user\/UserCustomerRuleManager\.vue$/u,
],
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects,
},
{ {
name: "pos", name: "pos",
patterns: [ patterns: [
@@ -160,19 +202,47 @@ export const sourceMappings = [
}, },
{ {
name: "system-status", name: "system-status",
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu], patterns: [/system[-/]?status/iu, /system(?:Database|Redis|Minio)/iu, /SystemDependencyDisplay/iu, /replication/iu],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"], specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects, projects: chromiumProjects,
}, },
{
name: "superuser-security",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/system\/SystemSecurity\.vue$/u,
/^src\/services\/superuserSecurity\.js$/u,
/superuser[-/]?security/iu,
/system\/security/iu,
],
specs: ["tests/e2e/superuser-security.spec.ts"],
projects: chromiumProjects,
},
{ {
name: "superuser-department-pricing", name: "superuser-department-pricing",
patterns: [ patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u, /^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|DepartmentCustomerPricing|SuperUserSelectedDepartmentObject|SuperUserDashboardDepartmentNavigation)\.vue$/u,
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
/^src\/services\/departmentCustomerPricing\.js$/u,
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u, /^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
], ],
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"], specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
projects: ["chromium-desktop"], projects: ["chromium-desktop"],
}, },
{
name: "superuser-department-shells",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentCategories\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentGatewaysWorkspacePage\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentProfile\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/DepartmentModulesSetup\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/SuperUserDashboardDepartmentModulesNavigation\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeSetup\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeTerminalsReaders\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/SuperUserDashboardDepartmentStripeNavigation\.vue$/u,
],
specs: ["tests/e2e/superuser-department-shells.spec.js"],
projects: chromiumProjects,
},
{ {
name: "self-serve", name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu], patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
+8 -1
View File
@@ -20,6 +20,7 @@ export const ownedFilesByRole = {
"auth.smoke.spec.js", "auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js", "booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts", "connectivityIssue.spec.ts",
"date-period-selector.smoke.spec.js",
"example.spec.ts", "example.spec.ts",
"guest-book-wash-mobile.spec.ts", "guest-book-wash-mobile.spec.ts",
"i18n-v2-integrity.spec.ts", "i18n-v2-integrity.spec.ts",
@@ -61,6 +62,7 @@ export const ownedFilesByRole = {
"admin-department-visibility.spec.ts", "admin-department-visibility.spec.ts",
"admin-overview-mobile.spec.ts", "admin-overview-mobile.spec.ts",
"admin-overview-night-washes.spec.ts", "admin-overview-night-washes.spec.ts",
"admin-pos-order-filters.spec.ts",
"admin-pos-drafts.spec.ts", "admin-pos-drafts.spec.ts",
"admin-pos-orders.spec.ts", "admin-pos-orders.spec.ts",
"adminModuleGoals.spec.ts", "adminModuleGoals.spec.ts",
@@ -79,7 +81,6 @@ export const ownedFilesByRole = {
"pos.visual.spec.js", "pos.visual.spec.js",
], ],
superuser: [ superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js", "edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js", "edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js", "edge-gateways.smoke.spec.js",
@@ -98,17 +99,23 @@ export const ownedFilesByRole = {
"self-serve-studio-flow.spec.js", "self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts", "session-bootstrap.spec.ts",
"superuser-bookings.spec.ts", "superuser-bookings.spec.ts",
"superuser-cron.spec.ts",
"superuser-customer-rules.spec.ts",
"superuser-customer-complaints.spec.ts", "superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts", "superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js", "superuser-department-branding.spec.js",
"superuser-department-shells.spec.js",
"superuser-department-overview.spec.js", "superuser-department-overview.spec.js",
"superuser-department-employees.spec.ts",
"superuser-department-gates.spec.ts", "superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts", "superuser-department-lanes.spec.ts",
"superuser-department-pricing-custom-only.spec.ts", "superuser-department-pricing-custom-only.spec.ts",
"superuser-departments-archive.spec.ts", "superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts", "superuser-drafts.spec.ts",
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts", "superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts", "superuser-roles-permissions.spec.ts",
"superuser-security.spec.ts",
"superuser-system-status.smoke.spec.js", "superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts", "superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js", "superuser-vehicles.smoke.spec.js",
+17 -5
View File
@@ -2,7 +2,13 @@ import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs"; import {
chromiumProjects,
fallbackChangePatterns,
prGrep,
smokeGrep,
sourceMappings,
} from "./playwright-pr-mapping.mjs";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const workingDirectory = process.cwd(); const workingDirectory = process.cwd();
@@ -245,7 +251,7 @@ function addSpec(selection, spec, projects) {
} }
function isE2eSpec(file) { function isE2eSpec(file) {
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file); return /^tests\/e2e\/(?!quarantine\/).+\.spec\.(?:js|ts)$/u.test(file);
} }
function shouldFallback(file) { function shouldFallback(file) {
@@ -361,7 +367,9 @@ async function runChangedSelection(selection) {
if (selection.fallback) { if (selection.fallback) {
console.log( console.log(
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}` `[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(
", "
)}`
); );
for (const project of projects) { for (const project of projects) {
const code = await runPlaywright({ const code = await runPlaywright({
@@ -386,7 +394,9 @@ async function runChangedSelection(selection) {
if (selection.skippedDirectSpecFiles.length > 0) { if (selection.skippedDirectSpecFiles.length > 0) {
console.log( console.log(
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}` `[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(
", "
)}`
); );
} }
@@ -466,7 +476,9 @@ async function main() {
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.`
);
return; return;
} }
+95 -16
View File
@@ -6,6 +6,7 @@ import favicon from "@/assets/favicon.ico";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { hasStoredSessionToken } from "@/services/sessionStorage.js"; import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import { import {
releaseChannelSwitchedStatus, releaseChannelSwitchedStatus,
releaseChannelUnavailableStatus, releaseChannelUnavailableStatus,
@@ -16,6 +17,20 @@ import {
const route = useRoute(); const route = useRoute();
const { t, te, locale } = useI18n({ useScope: "global" }); const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash"; const APP_TITLE = "Truck Wash";
const pageRouteLoadingProps = {
title: "Indlæser side...",
subtitle: "Vi gør siden klar",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Indlæser side",
};
const sessionRouteLoadingProps = {
title: "Bekræfter bruger...",
subtitle: "Vi indlæser din session",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Bekræfter bruger",
};
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue")); const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
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"));
@@ -81,6 +96,42 @@ const shouldRenderReleaseChannelSwitched = computed(
const shouldHideGlobalOverlays = computed( const shouldHideGlobalOverlays = computed(
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value () => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
); );
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
}
return window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, "") || "/";
};
const routeMiddlewareList = () =>
route.matched.flatMap((record) => {
const middleware = record.meta?.middleware;
if (!middleware) {
return [];
}
return Array.isArray(middleware) ? middleware : [middleware];
});
const hasRouteMiddleware = (middlewareName) =>
routeMiddlewareList().some((middleware) => middleware?.name === middlewareName);
const shouldUseSessionPathLoader = (path) =>
sessionLoaderRoutePattern.test(path || "") &&
(!guestSessionLoaderRoutePattern.test(path || "") || hasStoredSessionToken());
const shouldUseGuestSessionPathLoader = (path) =>
guestSessionLoaderRoutePattern.test(path || "") && hasStoredSessionToken();
const shouldUseSessionRouteLoader = computed(() =>
route.name !== "default" &&
(shouldUseSessionPathLoader(route.path || "") ||
shouldUseSessionPathLoader(currentBrowserPath()) ||
shouldUseGuestSessionPathLoader(route.path || "") ||
shouldUseGuestSessionPathLoader(currentBrowserPath()) ||
hasRouteMiddleware("authMiddleware") ||
(hasStoredSessionToken() && hasRouteMiddleware("guestMiddleware")))
);
const appRouteLoadingProps = computed(() =>
shouldUseSessionRouteLoader.value ? sessionRouteLoadingProps : pageRouteLoadingProps
);
const initiateStoredSession = async () => { const initiateStoredSession = async () => {
if (!hasStoredSessionToken()) { if (!hasStoredSessionToken()) {
@@ -111,22 +162,50 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</script> </script>
<template> <template>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" /> <Suspense :timeout="0">
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" /> <template #default>
<LayoutV2 v-else-if="route.meta?.template === 'with-header'"> <ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<router-view /> <ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
</LayoutV2> <LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<main v-else-if="route.meta?.template === 'clear-main'"> <router-view v-slot="{ Component }">
<router-view /> <Suspense :timeout="0">
</main> <component :is="Component" />
<template v-else> <template #fallback>
<header></header> <BrandedLoadingScreen v-bind="appRouteLoadingProps" />
<main> </template>
<DefaultPageWrapper> </Suspense>
<router-view /> </router-view>
</DefaultPageWrapper> </LayoutV2>
</main> <main v-else-if="route.meta?.template === 'clear-main'">
</template> <router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</DefaultPageWrapper>
</main>
</template>
</template>
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" /> <RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" /> <ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu /> <FrontendMaintenanceMenu />
+22 -5
View File
@@ -48,7 +48,24 @@ textarea.has-sharp-edges {
--bulma-box-shadow: none !important; --bulma-box-shadow: none !important;
--bulma-card-shadow: none !important; --bulma-card-shadow: none !important;
--bulma-skeleton-background: hsla(197 100% 35% / 0.70) !important; --bulma-skeleton-background: hsla(197 100% 35% / 0.7) !important;
--pleno-compact-table-header-font-size: 0.72rem;
--pleno-compact-table-header-line-height: 1;
}
body:not(.pleno-large-table-headers) .table thead th {
font-size: var(--pleno-compact-table-header-font-size);
line-height: var(--pleno-compact-table-header-line-height);
white-space: nowrap;
vertical-align: middle;
}
.pleno-table-header-content {
display: inline-flex;
align-items: center;
gap: 0.15rem;
max-width: 100%;
vertical-align: middle;
} }
/*:root {*/ /*:root {*/
@@ -104,16 +121,16 @@ textarea.has-sharp-edges {
margin-top: 30px !important; margin-top: 30px !important;
} }
.cell .card-content .field{ .cell .card-content .field {
width: 100%; width: 100%;
} }
.cell .card-content .field .label{ .cell .card-content .field .label {
font-weight: 800; font-weight: 800;
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.mb-8-mobile{ .mb-8-mobile {
margin-bottom: 8px !important; margin-bottom: 8px !important;
} }
} }
@@ -125,4 +142,4 @@ textarea.has-sharp-edges {
.mt-30-tablet { .mt-30-tablet {
margin-top: 30px !important; margin-top: 30px !important;
} }
} }
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, provide, reactive, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>;
const props = withDefaults(defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(), {
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
});
const emit = defineEmits<{
(event: "update:selected", value: any): void;
(event: "update:expandedKeys", value: any[]): void;
(event: "update:checkedKeys", value: any[]): void;
(event: "select", node: TreeNode, key: any): void;
(event: "check", node: TreeNode, key: any, checkedKeys: any[]): void;
(event: "expand", node: TreeNode, key: any): void;
(event: "collapse", node: TreeNode, key: any): void;
(event: "node-click", node: TreeNode, key: any): void;
(event: "load-start", node: TreeNode, key: any): void;
(event: "load-error", error: unknown, node: TreeNode, key: any): void;
}>();
const resolvedFields = computed(() => ({
id: props.fields?.id || "id",
label: props.fields?.label || "label",
children: props.fields?.children || "children",
isLeaf: props.fields?.isLeaf || "isLeaf",
disabled: props.fields?.disabled || "disabled",
}));
const state = reactive({
selected: props.selected,
expandedKeys: [...props.expandedKeys],
checkedKeys: [...props.checkedKeys],
lazyChildrenCache: {} as Record<string, TreeNode[]>,
loadingKeys: [] as any[],
loadErrorKeys: [] as any[],
renderedLimits: {} as Record<string, number>,
});
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? "");
const childrenOf = (node: TreeNode) => {
const children = node?.[resolvedFields.value.children];
return Array.isArray(children) ? children : [];
};
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
};
const progressiveBatchSize = computed(() => Math.max(0, Math.floor(Number(props.progressiveBatchSize) || 0)));
const visibleChildrenOf = (node: TreeNode) => {
const children = effectiveChildrenOf(node);
if (progressiveBatchSize.value < 1 || children.length <= progressiveBatchSize.value) {
return children;
}
const key = keyString(keyOf(node));
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
return children.slice(0, limit);
};
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
const showMoreChildren = (node: TreeNode) => {
if (!hasMoreChildren(node)) {
return;
}
const key = keyString(keyOf(node));
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
};
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
"{count}",
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
);
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
const isCheckDisabled = (node: TreeNode) => isDisabled(node) || (!isSelfSelectable(node) && !isBranchCheckable(node));
const isLeaf = (node: TreeNode) => Boolean(node?.[resolvedFields.value.isLeaf]);
const collectKeys = (node: TreeNode): any[] => {
const keys: any[] = [];
const key = keyOf(node);
if (key !== undefined && key !== null && isSelfSelectable(node)) {
keys.push(key);
}
effectiveChildrenOf(node).forEach((child) => {
keys.push(...collectKeys(child));
});
return keys;
};
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
const keys: any[] = [];
nodes.forEach((node) => {
const key = keyOf(node);
if (key !== undefined && key !== null && !isLeaf(node)) {
keys.push(key);
}
keys.push(...collectAllExpandableKeys(childrenOf(node)));
});
return keys;
};
watch(
() => props.selected,
(value) => {
state.selected = value;
}
);
watch(
() => props.expandedKeys,
(value) => {
state.expandedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.checkedKeys,
(value) => {
state.checkedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.data,
(nodes) => {
state.renderedLimits = {};
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
}
},
{ immediate: true, deep: true }
);
const setExpandedKeys = (keys: any[]) => {
state.expandedKeys = [...keys];
emit("update:expandedKeys", [...state.expandedKeys]);
};
const setCheckedKeys = (keys: any[]) => {
state.checkedKeys = [...new Set(keys)];
emit("update:checkedKeys", [...state.checkedKeys]);
};
const loadNode = async (node: TreeNode) => {
if (!props.lazy || !props.load) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.loadingKeys.includes(key)) {
return;
}
state.loadingKeys = [...state.loadingKeys, key];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
if (progressiveBatchSize.value > 0) {
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
}
} catch (error) {
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
} finally {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
};
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
if (!props.lazy || !props.load || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (childrenOf(node).length > 0 || state.lazyChildrenCache[keyString(key)]) {
return;
}
await loadNode(node);
};
const toggleExpand = async (node: TreeNode) => {
if (isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.expandedKeys.includes(key)) {
setExpandedKeys(state.expandedKeys.filter((current) => current !== key));
emit("collapse", node, key);
return;
}
setExpandedKeys([...state.expandedKeys, key]);
emit("expand", node, key);
if (props.lazy && childrenOf(node).length === 0 && !state.lazyChildrenCache[keyString(key)]) {
await loadNode(node);
}
};
const retryLoad = async (node: TreeNode) => {
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
delete state.lazyChildrenCache[keyString(key)];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
if (!state.expandedKeys.includes(key)) {
setExpandedKeys([...state.expandedKeys, key]);
}
await loadNode(node);
};
const toggleCheck = async (node: TreeNode) => {
if (props.selectionMode !== "checkbox" || isCheckDisabled(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
await ensureChildrenLoadedForCheck(node);
const keys = collectKeys(node);
if (keys.length === 0) {
emit("check", node, key, [...state.checkedKeys]);
return;
}
const allChecked = keys.every((current) => state.checkedKeys.includes(current));
if (allChecked) {
const toRemove = new Set(keys);
setCheckedKeys(state.checkedKeys.filter((current) => !toRemove.has(current)));
} else {
setCheckedKeys([...state.checkedKeys, ...keys]);
}
emit("check", node, key, [...state.checkedKeys]);
};
const checkState = (node: TreeNode): "checked" | "unchecked" | "indeterminate" => {
const key = keyOf(node);
const children = effectiveChildrenOf(node);
if (children.length === 0) {
return state.checkedKeys.includes(key) ? "checked" : "unchecked";
}
const states = children.map((child) => checkState(child));
if (states.every((item) => item === "checked")) {
return "checked";
}
if (states.every((item) => item === "unchecked") && !state.checkedKeys.includes(key)) {
return "unchecked";
}
return "indeterminate";
};
const handleNodeClick = async (node: TreeNode) => {
if (isDisabled(node)) {
return;
}
const key = keyOf(node);
emit("node-click", node, key);
if (props.selectionMode === "single") {
state.selected = key;
emit("update:selected", key);
emit("select", node, key);
} else if (props.selectionMode === "multiple") {
const selected = Array.isArray(state.selected) ? state.selected : [];
state.selected = selected.includes(key)
? selected.filter((current) => current !== key)
: [...selected, key];
emit("update:selected", state.selected);
emit("select", node, key);
}
if (props.expandOnClickNode) {
await toggleExpand(node);
}
};
provide("BuefyTreeContext", {
props,
state,
fields: resolvedFields,
keyOf,
childrenOf,
effectiveChildrenOf,
visibleChildrenOf,
hasMoreChildren,
showMoreChildren,
remainingChildrenCount,
loadMoreLabelFor,
isDisabled,
isCheckDisabled,
isLeaf,
toggleExpand,
retryLoad,
toggleCheck,
checkState,
handleNodeClick,
});
</script>
<template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
<BuefyTreeNode
v-for="(node, index) in data"
:key="keyOf(node) ?? index"
:node="node"
:depth="1"
:setsize="data.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps">
{{ slotProps.node?.[resolvedFields.label] }}
</slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
</ul>
</template>
<style scoped>
.b-tree {
list-style: none;
margin: 0;
padding: 0;
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { BCheckbox } from "buefy";
type TreeNode = Record<string, any>;
const props = defineProps<{
node: TreeNode;
depth: number;
setsize: number;
posinset: number;
}>();
const tree = inject<any>("BuefyTreeContext");
if (!tree) {
throw new Error("BuefyTreeNode must be used inside BuefyTree");
}
const keyValue = computed(() => tree.keyOf(props.node));
const keyString = computed(() => String(keyValue.value ?? ""));
const logicalChildren = computed(() => tree.effectiveChildrenOf(props.node));
const children = computed(() => tree.visibleChildrenOf(props.node));
const hasMoreChildren = computed(() => tree.hasMoreChildren(props.node));
const checkState = computed(() => tree.checkState(props.node));
const isExpanded = computed(() => tree.state.expandedKeys.includes(keyValue.value));
const isLoading = computed(() => tree.state.loadingKeys.includes(keyValue.value));
const hasLoadError = computed(() => tree.state.loadErrorKeys.includes(keyValue.value));
const isSelected = computed(() => {
const selected = tree.state.selected;
return Array.isArray(selected) ? selected.includes(keyValue.value) : selected === keyValue.value;
});
const isDisabled = computed(() => tree.isDisabled(props.node));
const isCheckDisabled = computed(() => tree.isCheckDisabled(props.node));
const isLeaf = computed(() => tree.isLeaf(props.node));
const hasExpandToggle = computed(() => {
if (isLeaf.value) {
return false;
}
return tree.props.lazy || children.value.length > 0;
});
const label = computed(() => String(props.node?.[tree.fields.value.label] ?? ""));
const loadMoreButton = ref<HTMLElement | null>(null);
let loadMoreObserver: IntersectionObserver | null = null;
const disconnectLoadMoreObserver = () => {
loadMoreObserver?.disconnect();
loadMoreObserver = null;
};
const observeLoadMoreButton = async () => {
disconnectLoadMoreObserver();
if (!isExpanded.value || !hasMoreChildren.value || typeof IntersectionObserver === "undefined") {
return;
}
await nextTick();
if (!loadMoreButton.value) {
return;
}
loadMoreObserver = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return;
}
disconnectLoadMoreObserver();
tree.showMoreChildren(props.node);
void observeLoadMoreButton();
}, { rootMargin: "160px 0px" });
loadMoreObserver.observe(loadMoreButton.value);
};
watch([isExpanded, hasMoreChildren], () => {
void observeLoadMoreButton();
}, { immediate: true });
onBeforeUnmount(disconnectLoadMoreObserver);
</script>
<template>
<li
class="b-tree-node"
:class="{
'is-expanded': isExpanded,
'is-selected': isSelected,
'is-disabled': isDisabled,
'is-loading': isLoading,
'has-load-error': hasLoadError,
}"
role="treeitem"
:aria-expanded="hasExpandToggle ? isExpanded : undefined"
:aria-selected="tree.props.selectionMode !== 'checkbox' && tree.props.selectionMode !== 'none' ? isSelected : undefined"
:aria-checked="tree.props.selectionMode === 'checkbox' ? (checkState === 'indeterminate' ? 'mixed' : checkState === 'checked') : undefined"
:aria-level="depth"
:aria-setsize="setsize"
:aria-posinset="posinset"
:aria-disabled="isDisabled || undefined"
:data-node-key="keyString"
>
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button
type="button"
class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }"
:disabled="isDisabled"
@click.stop="tree.toggleExpand(node)"
>
<span class="icon is-small">
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
</button>
<span
v-if="tree.props.selectionMode === 'checkbox'"
class="b-tree-node-checkbox"
@click.stop
>
<BCheckbox
:model-value="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:disabled="isCheckDisabled"
tabindex="-1"
@update:model-value="tree.toggleCheck(node)"
/>
</span>
<span class="b-tree-node-icon">
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
<span class="icon is-small">
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
</span>
</slot>
</span>
<span class="b-tree-node-label">
<slot
:node="node"
:data="node"
:depth="depth"
:expanded="isExpanded"
:checked="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:selected="isSelected"
:loading="isLoading"
:error="hasLoadError"
:retry="() => tree.retryLoad(node)"
>
{{ label }}
</slot>
</span>
</div>
<ul v-if="isExpanded && children.length > 0" class="b-tree-children" role="group">
<BuefyTreeNode
v-for="(child, index) in children"
:key="tree.keyOf(child) ?? index"
:node="child"
:depth="depth + 1"
:setsize="logicalChildren.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps"></slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
<li v-if="hasMoreChildren" class="b-tree-load-more" role="none">
<button
ref="loadMoreButton"
type="button"
class="button is-small is-light b-tree-load-more__button"
:aria-label="tree.loadMoreLabelFor(node)"
@click.stop="tree.showMoreChildren(node)"
>
<span class="icon is-small" aria-hidden="true"><i class="fas fa-chevron-down"></i></span>
<span>{{ tree.loadMoreLabelFor(node) }}</span>
</button>
</li>
</ul>
</li>
</template>
<style scoped>
.b-tree-node {
list-style: none;
}
.b-tree-node-content {
align-items: flex-start;
border-radius: 6px;
cursor: pointer;
display: flex;
gap: 0.35rem;
min-height: 2rem;
padding: 0.25rem 0.35rem;
}
.b-tree-node-content:hover {
background: #f5f7fa;
}
.b-tree-node.is-selected > .b-tree-node-content {
background: #eef4ff;
}
.b-tree-node.is-disabled > .b-tree-node-content {
cursor: default;
opacity: 0.65;
}
.b-tree-node-toggle {
flex: 0 0 1.85rem;
height: 1.75rem;
padding: 0;
width: 1.85rem;
}
.b-tree-node-checkbox {
flex: 0 0 auto;
margin-top: 0.08rem;
}
.b-tree-node-icon {
flex: 0 0 auto;
margin-top: 0.2rem;
}
.b-tree-node-label {
flex: 1 1 auto;
min-width: 0;
}
.b-tree-children {
border-left: 1px solid #dfe4ea;
list-style: none;
margin: 0 0 0 1.05rem;
padding: 0 0 0 0.65rem;
}
.b-tree-load-more {
list-style: none;
padding: 0.25rem 0 0.35rem 0.35rem;
}
.b-tree-load-more__button {
gap: 0.25rem;
}
.is-invisible {
visibility: hidden;
}
</style>
+8 -36
View File
@@ -1,12 +1,11 @@
<script setup> <script setup>
import { onMounted, ref, watch } from 'vue'; import { ref, watch } from 'vue';
import {SessionUser} from "@/components/session/token/SessionUser.vue"; import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.vue"; import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue"; import ShowErrorField from "@/components/global/ShowErrorField.vue";
import bulmaCalendar from "bulma-calendar";
import 'bulma-calendar/src/scss/index.scss';
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import {IS_DEV} from "@/config.js"; import {IS_DEV} from "@/config.js";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({ const props = defineProps({
form_identifier: { form_identifier: {
@@ -106,28 +105,6 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
} }
}); });
// Initialize the calendar
const createCalendar = (element, field) => {
const calendar = bulmaCalendar.attach(element, {
startDate: new Date(),
dateFormat: 'yyyy-MM-dd',
});
}
// Load all date fields
const renderCalendars = () => {
console.log('renderCalendars');
// Get all date fields
const dateFields = document.querySelectorAll('.date-calendar-input');
// Loop through the date fields
dateFields.forEach((field) => {
console.log(field);
// Create the calendar
createCalendar(field);
});
}
const isUserAdmin = ref(false); const isUserAdmin = ref(false);
const randomElementId = Math.random().toString(36).substring(7) const randomElementId = Math.random().toString(36).substring(7)
const fieldErrors = ref([]); const fieldErrors = ref([]);
@@ -519,10 +496,6 @@ SessionUser.objects.forms.get.single(props.form_identifier).then((response) => {
} }
fields.value = fields_tmp; fields.value = fields_tmp;
setDefaultValues(); setDefaultValues();
// Wait one tick before rendering the calendars
setTimeout(() => {
renderCalendars();
}, 0);
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
@@ -696,17 +669,16 @@ const debugGetForm = () => {
</div> </div>
<!-- Date --> <!-- Date -->
<div v-else-if="getValidator(field.validation).type === 'date'"> <div v-else-if="getValidator(field.validation).type === 'date'">
<input <BuefyDateField
class="input is-link" v-model="fieldValues[field.id]"
:type="getValidator(field.validation).type" value-type="string"
:required="isFieldRequired(field)" :required="isFieldRequired(field)"
:name="field.id" :name="field.id"
:id="field.id" :id="field.id"
@change="onFieldChange(field, $event.target.value)"
v-model="fieldValues[field.id]"
:disabled="isFieldLocked(field)" :disabled="isFieldLocked(field)"
v-bind:placeholder="field.metadata.placeholder" :placeholder="field.metadata.placeholder"
> @change="(value) => onFieldChange(field, value)"
/>
</div> </div>
</template> </template>
<!-- If the validator is not defined --> <!-- If the validator is not defined -->
+6 -1
View File
@@ -19,6 +19,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
userScopedUserId: {
type: [String, Number],
default: null,
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -42,7 +46,7 @@ const paginationKey = computed(() =>
const onInviteClick = async () => { const onInviteClick = async () => {
await SessionUser.objects.subusers.functions.showInviteForm(() => { await SessionUser.objects.subusers.functions.showInviteForm(() => {
paginationVersion.value += 1; paginationVersion.value += 1;
}, { superuser: props.superuserPage }); }, { superuser: props.superuserPage, userId: props.userScopedUserId });
}; };
</script> </script>
@@ -75,6 +79,7 @@ const onInviteClick = async () => {
:key="paginationKey" :key="paginationKey"
:endpoint="endpoint" :endpoint="endpoint"
:show-customer="showCustomer" :show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
auto-load="true" auto-load="true"
/> />
</div> </div>
+5 -3
View File
@@ -8,7 +8,7 @@ const { t } = useI18n();
</script> </script>
<template> <template>
<div> <div class="superuser-users-index" data-testid="superuser-users-index">
<PageTitle :title="t('superuser.pages.employees.title')" :subtitle="t('superuser.pages.employees.subtitle')"> <PageTitle :title="t('superuser.pages.employees.title')" :subtitle="t('superuser.pages.employees.subtitle')">
<template #buttons> <template #buttons>
<button class="button is-dark" @click="showCreateUserForm"> <button class="button is-dark" @click="showCreateUserForm">
@@ -24,5 +24,7 @@ const { t } = useI18n();
</template> </template>
<style scoped> <style scoped>
.superuser-users-index {
</style> min-width: 0;
}
</style>
+206 -43
View File
@@ -6,7 +6,8 @@ import Swal from "sweetalert2";
import CustomerProductDiscountDisplay import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue"; from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { isProductRestrictedForCustomer } from "@/features/customer/customerProductRules.js"; import { getCustomerProductRestriction } from "@/features/customer/customerProductRules.js";
import { BTooltip } from "buefy";
const { t } = useI18n(); const { t } = useI18n();
const expandIcon = ref(null); const expandIcon = ref(null);
@@ -31,6 +32,10 @@ const props = defineProps({
setProductAddonNote: Function, setProductAddonNote: Function,
customerDiscounts: Array, customerDiscounts: Array,
customerAttributes: Array, customerAttributes: Array,
customerAttributesStatus: {
type: String,
default: "ready",
},
compact: Boolean, compact: Boolean,
showPrices: { showPrices: {
type: Boolean, type: Boolean,
@@ -44,6 +49,21 @@ const customerAttributesSafe = computed(() => (Array.isArray(props.customerAttri
const getAddonProduct = (addon) => addon?.product || {}; const getAddonProduct = (addon) => addon?.product || {};
const getAddonName = (addon) => addon?.name || ''; const getAddonName = (addon) => addon?.name || '';
const getAddonTargetId = (addon) => addon?.option_id ?? getAddonProduct(addon).id ?? addon?.product_id ?? addon?.id;
const getReadinessRestriction = () => {
if (props.customerAttributesStatus === "ready") {
return null;
}
const isLoadFailure = props.customerAttributesStatus === "error";
return {
restricted: true,
rule: null,
rules: [],
messageKey: isLoadFailure ? "pos.restrictions.load_failed" : "pos.restrictions.loading",
};
};
const isAddonSelected = (productId, addonId) => { const isAddonSelected = (productId, addonId) => {
return selectedAddonsSafe.value.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true); return selectedAddonsSafe.value.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
@@ -69,6 +89,9 @@ const hideAddonPopper = (addon) => {
}; };
const emitAddToCart = (id) => { const emitAddToCart = (id) => {
if (isProductRestricted()) {
return;
}
emit('addToCartProduct', props.product); emit('addToCartProduct', props.product);
emit('add-to-cart', id); emit('add-to-cart', id);
}; };
@@ -80,10 +103,6 @@ const toggleIsSelected = () => {
} }
}; };
const hasRestrictAdditionalServices = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
};
/** /**
* On before add product addon * On before add product addon
* This is used to check if adding a product has any requirements. * This is used to check if adding a product has any requirements.
@@ -93,6 +112,9 @@ const hasRestrictAdditionalServices = () => {
* @param onAfterPreCheck * @param onAfterPreCheck
*/ */
const onBeforeAddProductAddon = (addon, onAfterPreCheck) => { const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
if (isAddonRestricted(addon)) {
return;
}
// Check if the addon already is selected // Check if the addon already is selected
if (isAddonSelected(props.id, addon.option_id)) { onAfterPreCheck(); return; } if (isAddonSelected(props.id, addon.option_id)) { onAfterPreCheck(); return; }
let note = null let note = null
@@ -123,6 +145,9 @@ const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
* @param onAfterPreCheck * @param onAfterPreCheck
*/ */
const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreCheck) => { const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreCheck) => {
if (!isAddonCurrentlySelected && isAddonRestricted(addon)) {
return;
}
// Debug: // Debug:
console.log(addon, isAddonCurrentlySelected); console.log(addon, isAddonCurrentlySelected);
let note = null let note = null
@@ -146,20 +171,56 @@ const doesAddonHaveNoteRequirement = (addon) => {
}; };
const isAddonRestricted = (addon) => { const isAddonRestricted = (addon) => {
const readinessRestriction = getReadinessRestriction();
if (readinessRestriction) {
return true;
}
const addonProduct = getAddonProduct(addon); const addonProduct = getAddonProduct(addon);
return isProductRestrictedForCustomer({ return getCustomerProductRestriction({
...addonProduct, ...addonProduct,
id: getAddonTargetId(addon),
name: getAddonName(addon) || addonProduct.name, name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category, category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value); }, customerAttributesSafe.value, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
}).restricted;
}; };
const isProductRestricted = () => { const getAddonRestrictionMessage = (addon) => {
return isProductRestrictedForCustomer({ const readinessRestriction = getReadinessRestriction();
if (readinessRestriction) {
return t(readinessRestriction.messageKey);
}
const addonProduct = getAddonProduct(addon);
const restriction = getCustomerProductRestriction({
...addonProduct,
id: getAddonTargetId(addon),
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
});
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const productRestriction = computed(() => {
const readinessRestriction = getReadinessRestriction();
if (readinessRestriction) {
return readinessRestriction;
}
return getCustomerProductRestriction({
...props.product, ...props.product,
name: props.name || props.product?.name, name: props.name || props.product?.name,
}, customerAttributesSafe.value); }, customerAttributesSafe.value);
}; });
const isProductRestricted = () => productRestriction.value.restricted;
const getProductRestrictionMessage = () => productRestriction.value.messageKey
? t(productRestriction.value.messageKey)
: "";
const orderByOrderPriority = (addons) => { const orderByOrderPriority = (addons) => {
return [...addons].sort((a, b) => { return [...addons].sort((a, b) => {
@@ -191,6 +252,13 @@ const orderByOrderPriority = (addons) => {
<template v-else> <template v-else>
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><br/></p> <p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><br/></p>
</template> </template>
<span
v-if="isProductRestricted()"
class="tag is-danger is-light is-small mt-1"
:data-testid="`pos-product-restriction-${id}`"
>
{{ getProductRestrictionMessage() }}
</span>
</div> </div>
</div> </div>
<!-- Price --> <!-- Price -->
@@ -216,6 +284,14 @@ const orderByOrderPriority = (addons) => {
<div class="column is-8-desktop"> <div class="column is-8-desktop">
<!-- Actual product details --> <!-- Actual product details -->
<p class="title is-6">{{ name }}</p> <p class="title is-6">{{ name }}</p>
<span
v-if="isProductRestricted()"
class="tag is-danger is-light mb-2"
:data-testid="`pos-product-restriction-${id}`"
>
<span class="icon is-small"><i class="fas fa-exclamation-triangle"></i></span>
<span>{{ getProductRestrictionMessage() }}</span>
</span>
<p class="subtitle is-6 mb-0" :style="{ 'color': Colors.global.primaryColor }"> <p class="subtitle is-6 mb-0" :style="{ 'color': Colors.global.primaryColor }">
No. {{ id }}</p> No. {{ id }}</p>
<p class="subtitle is-6">{{ description }}</p> <p class="subtitle is-6">{{ description }}</p>
@@ -227,20 +303,7 @@ const orderByOrderPriority = (addons) => {
<div v-if="isSelected" class="column is-12-desktop"> <div v-if="isSelected" class="column is-12-desktop">
<!-- Addons --> <!-- Addons -->
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0"> <div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
<template v-if="hasRestrictAdditionalServices()"> <template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id">
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
<button
class="button is-small is-fullwidth is-danger"
disabled
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>Ekstra ydelser er ikke tilladt</span>
</button>
</div>
</template>
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id" v-else>
<template v-if="!isAddonRestricted(addon)"> <template v-if="!isAddonRestricted(addon)">
<div <div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap" class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
@@ -249,6 +312,7 @@ const orderByOrderPriority = (addons) => {
<!-- Addon ( + ) --> <!-- Addon ( + ) -->
<button <button
class="button is-small" class="button is-small"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-increase`"
@click="onBeforeAddProductAddon(addon, () => addProductAddon( @click="onBeforeAddProductAddon(addon, () => addProductAddon(
props.id, props.id,
addon.option_id addon.option_id
@@ -261,6 +325,7 @@ const orderByOrderPriority = (addons) => {
<!-- quantity (if any), Addon name, price --> <!-- quantity (if any), Addon name, price -->
<button <button
class="button is-small is-fullwidth" class="button is-small is-fullwidth"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-name`"
:class="{ 'is-link': isAddonSelected(props.id, addon.option_id), 'is-light': !isAddonSelected(props.id, addon.option_id) }" :class="{ 'is-link': isAddonSelected(props.id, addon.option_id), 'is-light': !isAddonSelected(props.id, addon.option_id) }"
@click="onBeforeToggleProductAddon(addon, isAddonSelected(props.id, addon.option_id), () => toggleProductAddon(props.id, addon.option_id))" @click="onBeforeToggleProductAddon(addon, isAddonSelected(props.id, addon.option_id), () => toggleProductAddon(props.id, addon.option_id))"
@mouseenter="showAddonPopper(addon, $event.target)" @mouseenter="showAddonPopper(addon, $event.target)"
@@ -274,6 +339,7 @@ const orderByOrderPriority = (addons) => {
<!-- Addon ( - ) --> <!-- Addon ( - ) -->
<button <button
class="button is-small" class="button is-small"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-decrease`"
:disabled="!isAddonSelected(props.id, addon.option_id)" :disabled="!isAddonSelected(props.id, addon.option_id)"
@click="subtractProductAddon( @click="subtractProductAddon(
props.id, props.id,
@@ -287,18 +353,79 @@ const orderByOrderPriority = (addons) => {
</div> </div>
</template> </template>
<!-- Addon is restricted --> <!-- Addon is restricted -->
<template v-else> <template v-else>
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"> <div
<button class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
class="button is-small is-fullwidth is-danger" :data-testid="`pos-addon-restriction-${getAddonTargetId(addon)}`"
disabled >
> <BTooltip
<span class="icon is-small"> :label="getAddonRestrictionMessage(addon)"
<i class="fas fa-exclamation-triangle"></i> :triggers="['hover', 'focus', 'click']"
</span> multilined
<span>{{ addon.name }} er ikke tilladt</span> position="is-top"
</button> type="is-dark"
</div> append-to-body
>
<span
class="pos-restriction-tooltip-trigger"
tabindex="0"
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}-increase`"
>
<button
class="button is-small is-danger is-light"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-increase`"
disabled
>
<span class="icon is-small"><i class="fas fa-plus"></i></span>
</button>
</span>
</BTooltip>
<BTooltip
:label="getAddonRestrictionMessage(addon)"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="pos-restriction-tooltip-trigger pos-restriction-tooltip-trigger--grow"
tabindex="0"
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}`"
>
<button
class="button is-small is-fullwidth is-danger is-light"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-name`"
disabled
>
<span class="icon is-small"><i class="fas fa-exclamation-triangle"></i></span>
<span>{{ addon.name }} / {{ getAddonPrice(addon) }} Kr.</span>
</button>
</span>
</BTooltip>
<BTooltip
:label="getAddonRestrictionMessage(addon)"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="pos-restriction-tooltip-trigger"
tabindex="0"
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}-decrease`"
>
<button
class="button is-small is-danger is-light"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-decrease`"
disabled
>
<span class="icon is-small"><i class="fas fa-minus"></i></span>
</button>
</span>
</BTooltip>
</div>
</template> </template>
</template> </template>
</div> </div>
@@ -306,15 +433,37 @@ const orderByOrderPriority = (addons) => {
<p>&nbsp;</p> <p>&nbsp;</p>
<!-- Add to cart --> <!-- Add to cart -->
<div class="buttons is-centered mt-2 mb-3 pl-6"> <div class="buttons is-centered mt-2 mb-3 pl-6">
<button <BTooltip
v-bind:disabled="isProductRestricted()" v-if="isProductRestricted()"
:data-testid="`pos-add-to-cart-${id}`" :label="getProductRestrictionMessage()"
class="button is-link is-small is-fullwidth" :triggers="['hover', 'focus', 'click']"
@click="emitAddToCart(id);" multilined
position="is-top"
type="is-dark"
append-to-body
> >
<span class="icon is-small"> <span
<i class="fas fa-cart-plus"></i> class="pos-restriction-tooltip-trigger pos-restriction-tooltip-trigger--grow"
tabindex="0"
:data-testid="`pos-add-to-cart-restriction-tooltip-${id}`"
>
<button
disabled
:data-testid="`pos-add-to-cart-${id}`"
class="button is-small is-fullwidth is-danger is-light"
>
<span class="icon is-small"><i class="fas fa-cart-plus"></i></span>
<span>{{ t('pos.add_to_cart') }}</span>
</button>
</span> </span>
</BTooltip>
<button
v-else
:data-testid="`pos-add-to-cart-${id}`"
class="button is-small is-fullwidth is-link"
@click="emitAddToCart(id);"
>
<span class="icon is-small"><i class="fas fa-cart-plus"></i></span>
<span>{{ t('pos.add_to_cart') }}</span> <span>{{ t('pos.add_to_cart') }}</span>
</button> </button>
</div> </div>
@@ -349,4 +498,18 @@ const orderByOrderPriority = (addons) => {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.pos-restriction-tooltip-trigger {
display: inline-flex;
}
.pos-restriction-tooltip-trigger--grow {
flex: 1 1 auto;
min-width: 0;
}
.pos-restriction-tooltip-trigger--grow > .button {
width: 100%;
}
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,10 @@ const props = defineProps({
clickAction: Function, clickAction: Function,
icon: String, icon: String,
label: String, label: String,
detailValue: {
type: [String, Number],
default: "",
},
disabled: Boolean, disabled: Boolean,
testId: { testId: {
type: String, type: String,
@@ -107,6 +111,10 @@ const getLabel = () => {
return props.label ?? 'Unavngivet handling'; return props.label ?? 'Unavngivet handling';
} }
const hasDetailValue = () => {
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
}
const getStyle = () => { const getStyle = () => {
return styles[props.template] ?? styles.default; return styles[props.template] ?? styles.default;
} }
@@ -140,18 +148,40 @@ const getLabelColor = () => {
<span class="icon"> <span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i> <i :class="getIcon() + ' ' + getIconColor()"></i>
</span> </span>
<span class="ml-1" <span class="dropdown-item-action__label ml-1"
:class="getLabelColor()" :class="getLabelColor()"
>{{ getLabel() }}</span> >{{ getLabel() }}</span>
<span v-if="hasDetailValue()" class="dropdown-item-action__detail">
{{ props.detailValue }}
</span>
</button> </button>
</template> </template>
<style scoped> <style scoped>
.dropdown-item-action { .dropdown-item-action {
width: 100%; align-items: center;
text-align: left;
background: transparent; background: transparent;
border: 0; border: 0;
display: flex;
gap: 0.25rem;
text-align: left;
width: 100%;
}
.dropdown-item-action__label {
min-width: 0;
overflow-wrap: anywhere;
}
.dropdown-item-action__detail {
color: #718095;
font-size: 0.86rem;
font-weight: 600;
margin-left: auto;
min-width: 0;
overflow-wrap: anywhere;
padding-left: 0.75rem;
text-align: right;
} }
.is-disabled { .is-disabled {
@@ -8,6 +8,14 @@ const props = defineProps({
default: undefined, default: undefined,
}, },
label: String, label: String,
detailValue: {
type: [String, Number],
default: "",
},
appearance: {
type: String,
default: "default",
},
template: { template: {
type: String, type: String,
default: 'default', // The style of the button (default, danger, success, warning, info, light) default: 'default', // The style of the button (default, danger, success, warning, info, light)
@@ -86,6 +94,10 @@ const getLabel = () => {
return props.label ?? 'Unavngivet handling'; return props.label ?? 'Unavngivet handling';
} }
const hasDetailValue = () => {
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
}
const getStyle = () => { const getStyle = () => {
return styles[props.template] ?? styles.default; return styles[props.template] ?? styles.default;
} }
@@ -109,25 +121,55 @@ const getLabelColor = () => {
const hasIcon = () => { const hasIcon = () => {
return props.icon !== undefined && props.icon !== null; return props.icon !== undefined && props.icon !== null;
} }
const isMetadataAppearance = () => props.appearance === "metadata";
</script> </script>
<template> <template>
<a class="dropdown-item is-disabled"> <a
<span class="icon" v-if="hasIcon()"> class="dropdown-item dropdown-item-label is-disabled"
<i :class="getIcon() + ' ' + getIconColor()"></i> :class="{ 'dropdown-item-label--metadata': isMetadataAppearance() }"
</span> >
<span class="ml-1" <span class="icon" v-if="hasIcon()">
:class="getLabelColor()" <i :class="getIcon() + ' ' + getIconColor()"></i>
>{{ SessionUser.functions.ucFirst(getLabel()) }}</span> </span>
<span class="dropdown-item-label__text ml-1" :class="getLabelColor()">
{{ SessionUser.functions.ucFirst(getLabel()) }}
</span>
<span v-if="hasDetailValue()" class="dropdown-item-label__detail">
{{ props.detailValue }}
</span>
</a> </a>
</template> </template>
<style scoped> <style scoped>
.is-disabled { .is-disabled {
align-items: center;
display: flex;
justify-content: flex-start;
pointer-events: none; pointer-events: none;
text-align: left;
width: 100%;
opacity: 0.5; opacity: 0.5;
} }
.dropdown-item-label__text {
min-width: 0;
overflow-wrap: anywhere;
text-align: left;
}
.dropdown-item-label__detail {
color: #718095;
font-size: 0.86rem;
font-weight: 600;
margin-left: auto;
min-width: 0;
overflow-wrap: anywhere;
padding-left: 0.75rem;
text-align: right;
}
.is-disabled:hover { .is-disabled:hover {
background-color: transparent; background-color: transparent;
} }
@@ -135,4 +177,22 @@ const hasIcon = () => {
.is-disabled .icon { .is-disabled .icon {
opacity: 0.5; opacity: 0.5;
} }
</style>
.dropdown-item-label--metadata {
opacity: 1;
}
.dropdown-item-label--metadata .icon {
opacity: 1;
}
.dropdown-item-label--metadata .dropdown-item-label__text {
color: #4a5b73;
font-weight: 600;
}
.dropdown-item-label--metadata .dropdown-item-label__detail {
color: #25344d;
font-weight: 700;
}
</style>
@@ -0,0 +1,129 @@
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
changeAction: {
type: Function,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: "fas fa-cog",
},
label: {
type: String,
default: "",
},
options: {
type: Array,
default: () => [],
},
testId: {
type: String,
default: "",
},
value: {
type: [String, Number, Boolean],
default: "",
},
});
const root = ref(null);
const normalizeSelectValue = (value) => String(value);
const localValue = ref(normalizeSelectValue(props.value));
const isProcessing = ref(false);
watch(
() => props.value,
(value) => {
localValue.value = normalizeSelectValue(value);
}
);
const isDisabled = computed(() => props.disabled || isProcessing.value);
const optionKey = (option) => `${typeof option?.value}:${normalizeSelectValue(option?.value)}`;
const getInputValue = (value) => value?.target?.value ?? value;
const onInput = async (value) => {
const nextValue = normalizeSelectValue(getInputValue(value));
if (isDisabled.value || nextValue === normalizeSelectValue(props.value)) {
return;
}
isProcessing.value = true;
try {
if (props.changeAction) {
await props.changeAction(nextValue);
}
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
} finally {
isProcessing.value = false;
}
};
</script>
<template>
<div
ref="root"
class="dropdown-item action-settings-wheel-select-item"
:class="{ 'is-disabled': isDisabled }"
:data-testid="props.testId || undefined"
>
<span class="icon action-settings-wheel-select-item__icon">
<i :class="props.icon"></i>
</span>
<span class="action-settings-wheel-select-item__label">
{{ props.label }}
</span>
<b-select
v-model="localValue"
size="is-small"
:disabled="isDisabled"
:aria-label="props.label"
:data-testid="props.testId ? `${props.testId}-select` : undefined"
@click.stop
@mousedown.stop
@update:modelValue="onInput"
>
<option v-for="option in props.options" :key="optionKey(option)" :value="normalizeSelectValue(option.value)">
{{ option.label }}
</option>
</b-select>
</div>
</template>
<style scoped>
.action-settings-wheel-select-item {
align-items: center;
display: flex;
gap: 0.35rem;
width: 100%;
}
.action-settings-wheel-select-item__icon {
flex: 0 0 auto;
}
.action-settings-wheel-select-item__label {
color: #25344d;
flex: 1 1 auto;
font-weight: 500;
min-width: 0;
overflow-wrap: anywhere;
}
.action-settings-wheel-select-item :deep(.select),
.action-settings-wheel-select-item :deep(select) {
max-width: 9.5rem;
}
.action-settings-wheel-select-item.is-disabled {
opacity: 0.55;
}
</style>
@@ -62,7 +62,11 @@ const onClick = async () => {
:title="title" :title="title"
@click.stop.prevent="onClick" @click.stop.prevent="onClick"
> >
<span class="action-settings-wheel-toggle-item__label">{{ label }}</span> <span class="action-settings-wheel-toggle-item__label">
<slot name="label" :label="label">
{{ label }}
</slot>
</span>
<span class="action-settings-wheel-toggle-item__switch" :class="{ 'is-checked': checked }" aria-hidden="true"> <span class="action-settings-wheel-toggle-item__switch" :class="{ 'is-checked': checked }" aria-hidden="true">
<span class="action-settings-wheel-toggle-item__knob"></span> <span class="action-settings-wheel-toggle-item__knob"></span>
</span> </span>
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import {
customer_id, customer_id,
customer_data, customer_data,
customer_attributes, customer_attributes,
department_id,
loadCustomerAttributes, loadCustomerAttributes,
hideDiscountsCatalog, hideDiscountsCatalog,
hidePricesCatalog, hidePricesCatalog,
@@ -20,17 +21,11 @@ import "bulma-switch/dist/css/bulma-switch.min.css";
import "bulma-block-list/src/block-list.scss"; import "bulma-block-list/src/block-list.scss";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import {
popperBox,
popper,
removePopperIfOpen,
showPopperWithContent,
showPopper,
} from "@/components/displays/PopperDefault.vue";
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue"; import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue"; import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue"; import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js"; import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
const { t } = useI18n(); const { t } = useI18n();
const emit = defineEmits(["update:activeTab"]); const emit = defineEmits(["update:activeTab"]);
@@ -172,6 +167,9 @@ const refreshCustomerAttributesForRules = async (selectedCustomerNumber = custom
const hasAttribute = (prop) => { const hasAttribute = (prop) => {
return customer_attributes.value.some((attribute) => attribute.attribute === prop); return customer_attributes.value.some((attribute) => attribute.attribute === prop);
}; };
const attributeEntry = (prop) => (
customer_attributes.value.find((attribute) => attribute.attribute === prop) ?? null
);
watch( watch(
activeTabKey, activeTabKey,
@@ -276,12 +274,16 @@ const customer_data_has_empty_details = () => {
<span class="panel-icon pos-selected-customer__icon"> <span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i> <i :class="attribute.icon" aria-hidden="true"></i>
</span> </span>
<span <CustomerRuleTooltip
class="pos-selected-customer__label" :attribute="attribute.attribute"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)" :active="hasAttribute(attribute.prop)"
@mouseleave="removePopperIfOpen()" :customer-number="customer_id"
>{{ attribute.name }}</span :department-id="department_id"
:restriction="attributeEntry(attribute.prop)"
:test-id="`pos-customer-rule-tooltip-${attribute.attribute}`"
> >
<span class="pos-selected-customer__label">{{ attribute.name }}</span>
</CustomerRuleTooltip>
<span class="pos-selected-customer__value"> <span class="pos-selected-customer__value">
<span <span
v-if="hasAttribute(attribute.prop)" v-if="hasAttribute(attribute.prop)"
@@ -0,0 +1,122 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
const props = defineProps({
selectedInvoiceCollectionIds: {
type: Array,
default: () => [],
},
totalInvoiceCollectionCount: {
type: Number,
default: 0,
},
allSelected: {
type: Boolean,
default: false,
},
allExpanded: {
type: Boolean,
default: false,
},
invoiceQueueBusy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["invoiceSelected", "bulkAction", "toggleSelectAll", "toggleExpandAll"]);
const { t } = useI18n();
const selectedCount = computed(() => props.selectedInvoiceCollectionIds.length);
const hasSelectableCollections = computed(() => props.totalInvoiceCollectionCount > 0);
const hasSelectedCollections = computed(() => selectedCount.value > 0);
const hasMultipleSelectedCollections = computed(() => selectedCount.value > 1);
const triggerLabel = computed(() =>
t("invoicing_period.invoice_collection_actions.menu.label", { count: selectedCount.value })
);
const selectAllLabel = computed(() =>
props.allSelected
? t("invoicing_period.invoice_collection_actions.menu.unselect_all")
: t("invoicing_period.invoice_collection_actions.menu.select_all")
);
const expandAllLabel = computed(() =>
props.allExpanded
? t("invoicing_period.invoice_collection_actions.menu.collapse_all")
: t("invoicing_period.invoice_collection_actions.menu.expand_all")
);
const emitBulkAction = (action) => emit("bulkAction", action);
</script>
<template>
<div
v-if="hasSelectableCollections"
class="invoice-collection-selection-action-wheel"
data-testid="invoice-collection-selection-action-wheel"
>
<ActionSettingsWheelButton icon="fas fa-sliders-h" :label="triggerLabel">
<template #actions>
<ActionSettingsWheelItemLabel
:label="t('invoicing_period.invoice_collection_actions.menu.selection_section')"
data-testid="invoice-collection-selection-action-wheel-selection-section"
/>
<ActionSettingsWheelItem
icon="fas fa-check-double"
:label="selectAllLabel"
:click-action="() => emit('toggleSelectAll')"
test-id="invoice-collection-selection-action-wheel-select-all"
/>
<ActionSettingsWheelItem
:icon="allExpanded ? 'fas fa-compress-alt' : 'fas fa-expand-alt'"
:label="expandAllLabel"
:click-action="() => emit('toggleExpandAll')"
test-id="invoice-collection-selection-action-wheel-expand-all"
/>
<template v-if="hasSelectedCollections">
<ActionSettingsWheelItemLabel
:label="t('invoicing_period.invoice_collection_actions.menu.modification_section')"
data-testid="invoice-collection-selection-action-wheel-modification-section"
/>
<ActionSettingsWheelItem
icon="fas fa-file-invoice-dollar"
:label="t('invoicing_period.invoice_collection_actions.actions.queue_economic')"
:click-action="() => emit('invoiceSelected')"
:disabled="invoiceQueueBusy"
test-id="invoice-collection-selection-action-wheel-invoice-selected"
/>
<ActionSettingsWheelItem
icon="fas fa-broom"
:label="t('invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES)"
test-id="invoice-collection-selection-action-wheel-clean-rules"
/>
<ActionSettingsWheelItem
v-if="hasMultipleSelectedCollections"
icon="fas fa-compress-arrows-alt"
:label="t('invoicing_period.invoice_collection_actions.actions.merge_collections')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.MERGE)"
test-id="invoice-collection-selection-action-wheel-merge"
/>
<ActionSettingsWheelItem
icon="fas fa-calendar-alt"
:label="t('invoicing_period.invoice_collection_actions.actions.split_by_month')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH)"
test-id="invoice-collection-selection-action-wheel-split-by-month"
/>
<ActionSettingsWheelItem
icon="fas fa-undo"
:label="t('invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES)"
test-id="invoice-collection-selection-action-wheel-reset-hidden-prices"
/>
</template>
</template>
</ActionSettingsWheelButton>
</div>
</template>
@@ -0,0 +1,7 @@
export const INVOICE_COLLECTION_BULK_ACTIONS = Object.freeze({
CLEAN_CUSTOMER_RULES: "remove_customer_rule_violations",
MERGE: "merge_collections",
SPLIT_BY_MONTH: "split_by_month",
RESET_HIDDEN_PRICES: "reset_hidden_item_prices",
QUEUE_ECONOMIC: "queue_economic",
});
File diff suppressed because it is too large Load Diff
@@ -38,14 +38,17 @@ import {
reg_2, reg_2,
reg_3, reg_3,
department_id, department_id,
customer_id, customer_id,
getCustomerEmail, customer_attributes,
customer_name, customer_attributes_status,
isAddonRestricted, getCustomerEmail,
canBuyAdditionalServices, customer_name,
registerPosStepSaveBarrier, getAddonRestriction,
saveOrderMetadataField, getProductRestriction,
} from "@/components/shop/POSDepartmentProcess.vue"; retryCustomerAttributes,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue"; import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue"; import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue"; import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
@@ -343,13 +346,14 @@ const applyPendingBookingFromSelection = async () => {
effectivePrimaryProduct = firstWash; effectivePrimaryProduct = firstWash;
} }
} }
effectivePrimaryProduct.addons = preparedAddons as any; effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any); transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) { if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any; transactionItems.primaryItem.value.addons = preparedAddons as any;
} }
sanitizeRestrictedTransactionItems();
lastAppliedBookingId.value = booking.id; lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons); //console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value); //console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
@@ -481,9 +485,127 @@ const layout = {
}; };
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27; const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi"; const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const restrictionWarningMessageKey = ref("");
const showRestrictedItemsRemovedWarning = () => {
restrictionWarningMessageKey.value = "pos.restrictions.restricted_items_removed";
};
const getRestrictionReadinessMessageKey = () =>
customer_attributes_status.value === "error" ? "pos.restrictions.load_failed" : "pos.restrictions.loading";
const getMobileAddonRestriction = (addon: any) => getAddonRestriction(addon, { isRelatedAddon: true });
const getStandaloneAdditionalItemRestriction = (item: any) =>
getProductRestriction(item, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
});
const isMobileAddonRestricted = (addon: any) => getMobileAddonRestriction(addon).restricted;
const isStandaloneAdditionalItemRestricted = (item: any) => getStandaloneAdditionalItemRestriction(item).restricted;
const decorateMobileAddonRestriction = (addon: any) => {
const restriction = getMobileAddonRestriction(addon);
const nextQuantity = restriction.restricted ? 0 : Number(addon?.quantity ?? addon?.product?.quantity ?? 0);
const decoratedAddon = {
...addon,
quantity: nextQuantity,
product: addon?.product
? {
...addon.product,
quantity: nextQuantity,
}
: addon?.product,
};
if (restriction.restricted) {
decoratedAddon.restricted = true;
decoratedAddon.restrictionRule = restriction.rule;
decoratedAddon.restrictionMessageKey = restriction.messageKey;
}
return decoratedAddon;
};
const sanitizeRestrictedTransactionItems = () => {
if (customer_attributes_status.value !== "ready") {
return false;
}
let removedRestrictedItem = false;
const primary = transactionItems.primaryItem.value;
if (primary && Array.isArray(primary.addons)) {
let changedAddons = false;
const sanitizedAddons = primary.addons.map((addon: any) => {
const restriction = getMobileAddonRestriction(addon);
if (!restriction.restricted) {
return addon;
}
if (Number(addon?.quantity ?? addon?.product?.quantity ?? 0) > 0) {
removedRestrictedItem = true;
}
if (
addon?.restricted === true &&
addon?.restrictionRule === restriction.rule &&
addon?.restrictionMessageKey === restriction.messageKey &&
Number(addon?.quantity ?? 0) === 0 &&
Number(addon?.product?.quantity ?? 0) === 0
) {
return addon;
}
changedAddons = true;
return {
...addon,
restricted: true,
restrictionRule: restriction.rule,
restrictionMessageKey: restriction.messageKey,
quantity: 0,
product: addon?.product
? {
...addon.product,
quantity: 0,
}
: addon?.product,
};
});
if (changedAddons) {
primary.addons = sanitizedAddons;
}
}
const allowedAdditionalItems = (transactionItems.additionalItems.value || []).filter((item: any) => {
const restricted = isStandaloneAdditionalItemRestricted(item);
if (restricted && Number(item?.quantity ?? 0) > 0) {
removedRestrictedItem = true;
}
return !restricted;
});
if (allowedAdditionalItems.length !== (transactionItems.additionalItems.value || []).length) {
transactionItems.setAdditionalItems(allowedAdditionalItems);
}
if (removedRestrictedItem) {
showRestrictedItemsRemovedWarning();
}
return !removedRestrictedItem;
};
const onCopyLastOrder = (vehicleIndex: number) => { const onCopyLastOrder = (vehicleIndex: number) => {
if (customer_attributes_status.value !== "ready") {
restrictionWarningMessageKey.value = getRestrictionReadinessMessageKey();
return;
}
lastOrders.select(vehicleIndex); lastOrders.select(vehicleIndex);
sanitizeRestrictedTransactionItems();
}; };
const openCustomerSelection = () => { const openCustomerSelection = () => {
@@ -786,10 +908,11 @@ const buildDesiredOrderItemShapes = () => {
related_item_id: null, related_item_id: null,
price: Number(transactionItems.primaryItem.value.price ?? 0), price: Number(transactionItems.primaryItem.value.price ?? 0),
notes: String(transactionItems.primaryItem.value?.notes ?? ""), notes: String(transactionItems.primaryItem.value?.notes ?? ""),
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
}; };
const addonShapes = (transactionItems.primaryItem.value.addons || []) const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => { .map((addon: any) => {
const addonProduct = addon?.product ?? addon; const addonProduct = addon?.product ?? addon;
return { return {
@@ -800,11 +923,12 @@ const buildDesiredOrderItemShapes = () => {
related_item_id: "__PRIMARY__", related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0), price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""), notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
}; };
}); });
const additionalShapes = (transactionItems.additionalItems.value || []) const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({ .map((item: any) => ({
kind: "additional", kind: "additional",
relatedKey: null, relatedKey: null,
@@ -813,6 +937,7 @@ const buildDesiredOrderItemShapes = () => {
related_item_id: null, related_item_id: null,
price: Number(item?.price ?? 0), price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""), notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
})); }));
return [primaryShape, ...addonShapes, ...additionalShapes]; return [primaryShape, ...addonShapes, ...additionalShapes];
@@ -864,8 +989,8 @@ const buildCurrentSelectionComparableShapes = () => {
}; };
const addonShapes = sortComparableLastWashShapes( const addonShapes = sortComparableLastWashShapes(
(transactionItems.primaryItem.value.addons || []) (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({ .map((addon: any) => ({
kind: "addon", kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0), product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
@@ -874,8 +999,8 @@ const buildCurrentSelectionComparableShapes = () => {
); );
const additionalShapes = sortComparableLastWashShapes( const additionalShapes = sortComparableLastWashShapes(
(transactionItems.additionalItems.value || []) (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({ .map((item: any) => ({
kind: "additional", kind: "additional",
product_id: Number(item?.id ?? 0), product_id: Number(item?.id ?? 0),
@@ -944,13 +1069,21 @@ const syncCurrentTransactionToOrder = async () => {
throw new Error("No primary item selected"); throw new Error("No primary item selected");
} }
const primaryRestriction = getProductRestriction(transactionItems.primaryItem.value);
if (primaryRestriction.restricted) {
restrictionWarningMessageKey.value = primaryRestriction.messageKey;
return false;
}
const existingItemsResponse = await getOrderItems(normalizedOrderId); const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : []; const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes(); const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems); const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({ kind, relatedKey, skip_price_override, ...shape }) => shape);
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) { if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
return true; return true;
} }
@@ -962,12 +1095,12 @@ const syncCurrentTransactionToOrder = async () => {
1, 1,
null, null,
transactionItems.primaryItem.value?.notes || "", transactionItems.primaryItem.value?.notes || "",
transactionItems.primaryItem.value.price transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
); );
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id; const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || []) const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => { .map((addon: any) => {
const addonProduct = addon?.product ?? addon; const addonProduct = addon?.product ?? addon;
return createOrderItem( return createOrderItem(
@@ -976,14 +1109,23 @@ const syncCurrentTransactionToOrder = async () => {
Number(addon.quantity), Number(addon.quantity),
createdPrimaryItemId, createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "", addonProduct?.notes || addon?.notes || "",
addonProduct.price ?? addon.price addonProduct?.skip_price_override === true || addon?.skip_price_override === true
? null
: addonProduct.price ?? addon.price
); );
}); });
const additionalPromises = (transactionItems.additionalItems.value || []) const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => .map((item: any) =>
createOrderItem(normalizedOrderId, item.id, Number(item.quantity), null, item?.notes || "", item.price) createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || "",
item?.skip_price_override === true ? null : item.price
)
); );
await Promise.all([...addonPromises, ...additionalPromises]); await Promise.all([...addonPromises, ...additionalPromises]);
@@ -1022,8 +1164,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
missingProducts.push(primaryProduct); missingProducts.push(primaryProduct);
} }
(primaryProduct?.addons || []) (primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => { .forEach((addon: any) => {
const addonProduct = addon?.product ?? addon; const addonProduct = addon?.product ?? addon;
if ( if (
@@ -1035,8 +1177,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
} }
}); });
(transactionItems.additionalItems.value || []) (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => { .forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) { if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item); missingProducts.push(item);
@@ -1126,6 +1268,14 @@ const onBeforeComplete = async () => {
throw new Error("No primary item selected"); throw new Error("No primary item selected");
} }
const primaryRestriction = getProductRestriction(transactionItems.primaryItem.value);
if (primaryRestriction.restricted) {
restrictionWarningMessageKey.value = primaryRestriction.messageKey;
return false;
}
sanitizeRestrictedTransactionItems();
if (!(await ensureRequiredOrderItemNotes())) { if (!(await ensureRequiredOrderItemNotes())) {
return false; return false;
} }
@@ -1156,13 +1306,20 @@ const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
const sourceAddonProductId = getAddonProductId(addon); const sourceAddonProductId = getAddonProductId(addon);
const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId); const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId);
const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0; const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0;
const skipPriceOverride =
previousAddon?.skip_price_override === true ||
previousAddon?.product?.skip_price_override === true ||
addon?.skip_price_override === true ||
addon?.product?.skip_price_override === true;
return { return {
...addon, ...addon,
quantity: nextQuantity, quantity: nextQuantity,
skip_price_override: skipPriceOverride,
product: addon?.product product: addon?.product
? { ? {
...addon.product, ...addon.product,
quantity: nextQuantity, quantity: nextQuantity,
skip_price_override: skipPriceOverride,
} }
: addon?.product, : addon?.product,
}; };
@@ -1272,6 +1429,20 @@ watch(
} }
} }
); );
watch(
[
() => transactionItems.primaryItem.value?.addons,
() => transactionItems.additionalItems.value,
() => customer_attributes.value,
() => customer_attributes_status.value,
],
() => {
sanitizeRestrictedTransactionItems();
},
{ deep: true }
);
// Watch for changes in the vehicle 1 reference and update the reference field when it changes // Watch for changes in the vehicle 1 reference and update the reference field when it changes
watch( watch(
() => vehicles.vehicle_1.value?.reference, () => vehicles.vehicle_1.value?.reference,
@@ -1282,20 +1453,30 @@ watch(
} }
); );
// Computed property to filter out restricted addons based on customer attributes // Computed property to annotate restricted addons based on customer attributes
const filteredAddons = computed(() => { const filteredAddons = computed(() => {
const addons = transactionItems.primaryItem.value?.addons || []; const addons = transactionItems.primaryItem.value?.addons || [];
// If additional services are restricted, return empty array return addons.map((addon: any) => decorateMobileAddonRestriction(addon));
if (!canBuyAdditionalServices()) {
return [];
}
// Filter out individually restricted addons
return addons.filter((addon: any) => !isAddonRestricted(addon));
}); });
</script> </script>
<template> <template>
<template v-if="vehicleSelection"> <template v-if="vehicleSelection">
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-3"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false" /> <PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false" />
<!-- Buttons --> <!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step"> <PosDepartmentStepMobileFixedBottomControl variant="pos-step">
@@ -1353,10 +1534,32 @@ const filteredAddons = computed(() => {
<i class="fa fa-search"></i> <i class="fa fa-search"></i>
</span> </span>
</button> </button>
<!-- Registration numbers --> <!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" /> <PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<!-- Product --> <p
<PosDepartmentStepMobile2Product v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown" v-on:pointerdown="onPrimaryProductPointerDown"
v-on:pointermove="onPrimaryProductPointerMove" v-on:pointermove="onPrimaryProductPointerMove"
v-on:pointerup="onPrimaryProductPointerUp" v-on:pointerup="onPrimaryProductPointerUp"
@@ -1,59 +1,63 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from "vue"; import { ref, computed, watch, onMounted } from "vue";
import { PosOrder } from "../objects/PosOrder.vue"; import { PosOrder } from "../objects/PosOrder.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue"; import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue"; import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue"; import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobile2CategoryProduct import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue"; import PosDepartmentStepMobile2Addons from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import PosDepartmentStepMobile2Addons import { Addon } from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue"; import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue"; import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import PosDepartmentStepMobileFixedBottomControl import { transactionItems } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue"; import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { import {
transactionItems customer_id,
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; customer_attributes_status,
import PosDepartmentStep2MobileVehicleSelection getProductRestriction,
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue"; retryCustomerAttributes,
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile2FloatingCart import { useI18n } from "vue-i18n";
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue"; import { orderProducts } from "@/components/shop/Products.vue";
import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
const { t } = useI18n();
const ADDITIONAL_SERVICES_CATEGORY_ID = 8;
let latestAdditionalSelectionProductsRequestId = 0;
const props = defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
default: "Additional items", default: "Additional items",
required: true required: true,
}, },
subtitle: { subtitle: {
type: String, type: String,
default: "Click to modify your additional items", default: "Click to modify your additional items",
required: true required: true,
}, },
defaultChecked: { defaultChecked: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
lastOrder: { lastOrder: {
type: Object as () => PosOrder | null, type: Object as () => PosOrder | null,
default: null, default: null,
required: false required: false,
}, },
}); });
const canSelectAdditionalItems = computed(() => canBuyAdditionalServices()); const restrictionWarningMessageKey = ref("");
const additionalSelectionProducts = ref<PosProduct[]>([]);
const additionalSelectionLoading = ref(false);
const canSelectAdditionalItems = computed(() => true);
const checked = ref(props.defaultChecked); const checked = ref(props.defaultChecked);
// Function to generate a summary from the last order // Function to generate a summary from the last order
function generateSummary(order: PosOrder): string { function generateSummary(order: PosOrder): string {
if (!order || !order.items || order.items.length === 0) { if (!order || !order.items || order.items.length === 0) {
return "Denne ordre har ingen varer."; return "Denne ordre har ingen varer.";
} }
const itemNames = order.items.map(item => item?.product?.name || "Ukendt vare"); const itemNames = order.items.map((item) => item?.product?.name || "Ukendt vare");
const uniqueItems = Array.from(new Set(itemNames)); const uniqueItems = Array.from(new Set(itemNames));
return uniqueItems.length > 1 return uniqueItems.length > 1
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}` ? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
@@ -67,18 +71,10 @@ const displayLabel = computed(() => {
: props.label; : props.label;
}); });
const displaySubtitle = computed(() => { const displaySubtitle = computed(() => {
return props.lastOrder return props.lastOrder ? generateSummary(props.lastOrder) : props.subtitle;
? generateSummary(props.lastOrder)
: props.subtitle;
}); });
// Emit event on toggle // Emit event on toggle
function onToggle(isOpen: boolean) { function onToggle(isOpen: boolean) {
if (isOpen && !canSelectAdditionalItems.value) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
return;
}
checked.value = isOpen; checked.value = isOpen;
// Open the additional item selection view if toggled open // Open the additional item selection view if toggled open
if (isOpen) { if (isOpen) {
@@ -91,19 +87,22 @@ const exampleProducts = ref<PosProduct[]>([
{ {
id: 1, id: 1,
name: "Extra Towel", name: "Extra Towel",
price: 5.00, price: 5.0,
description: "A soft extra towel", description: "A soft extra towel",
subscription_allowed: false, subscription_allowed: false,
}, },
{ {
id: 2, id: 2,
name: "Premium Soap", name: "Premium Soap",
price: 3.50, price: 3.5,
description: "A premium quality soap", description: "A premium quality soap",
subscription_allowed: true, subscription_allowed: true,
} },
]); ]);
const convertProductToAddon = (product: PosProduct, options: {quantity?: number, min?: number, max?: number} = {}): Addon => { const convertProductToAddon = (
product: PosProduct,
options: { quantity?: number; min?: number; max?: number } = {}
): Addon => {
//console.warn("Converting product to addon:", product, options); //console.warn("Converting product to addon:", product, options);
return { return {
id: product.id, id: product.id,
@@ -113,88 +112,200 @@ const convertProductToAddon = (product: PosProduct, options: {quantity?: number,
quantity: options.quantity || 0, quantity: options.quantity || 0,
min: options.min || -1, min: options.min || -1,
max: options.max || -1, max: options.max || -1,
} };
} };
const getAvailableAdditionalItems = () => { const getAvailableAdditionalItems = () => {
const tmp = transactionItems.additionalItems.value || []; const tmp = transactionItems.additionalItems.value || [];
if (!tmp || tmp.length === 0) return []; if (!tmp || tmp.length === 0) return [];
return <Addon[]>tmp.map(p => convertProductToAddon(p, { return <Addon[]>tmp.map((p) =>
quantity: p?.quantity || 0, convertProductToAddon(p, {
min: -1, quantity: p?.quantity || 0,
max: -1, min: -1,
})); max: -1,
} })
);
};
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems()); const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
const isAdditionalItemRestricted = (product: PosProduct) => { const isAdditionalItemRestricted = (product: PosProduct) => {
if (!canSelectAdditionalItems.value) { return getAdditionalItemRestriction({ product } as Addon).restricted;
return true; };
}
if (isProductRestricted(product)) {
return true;
}
return isAddonRestricted(convertProductToAddon(product));
}
watch(canSelectAdditionalItems, (canSelect) => { const getAdditionalSelectionRestrictionMessage = (product: PosProduct) => {
if (!canSelect) { const restriction = getAdditionalItemRestriction({ product } as Addon);
checked.value = false; return restriction.messageKey ? t(restriction.messageKey) : "";
pos.views.additionalItemSelection.value = false; };
const getAdditionalItemRestriction = (addon: Addon) => {
return getProductRestriction(addon.product || addon, {
isStandaloneAdditionalService: true,
});
};
const decorateAdditionalItemAddon = (addon: Addon): Addon => {
const restriction = getAdditionalItemRestriction(addon);
const nextAddon = {
...addon,
quantity: restriction.restricted ? 0 : addon.quantity,
product: addon.product
? {
...addon.product,
quantity: restriction.restricted ? 0 : addon.product.quantity,
}
: addon.product,
} as Addon;
if (restriction.restricted) {
(nextAddon as any).restricted = true;
(nextAddon as any).restrictionRule = restriction.rule;
(nextAddon as any).restrictionMessageKey = restriction.messageKey;
} }
});
return nextAddon;
};
const showRestrictedItemsRemovedWarning = () => {
restrictionWarningMessageKey.value = "pos.restrictions.restricted_items_removed";
};
// Computed property to filter out restricted additional items based on customer attributes // Computed property to filter out restricted additional items based on customer attributes
const filteredAdditionalItems = computed(() => { const filteredAdditionalItems = computed(() => {
// If additional services are restricted, return empty array return availableAdditionalItems.value.map((addon: Addon) => decorateAdditionalItemAddon(addon));
if (!canSelectAdditionalItems.value) {
return [];
}
// Filter out individually restricted addons
return availableAdditionalItems.value.filter((addon: Addon) => {
if (isAddonRestricted(addon)) {
return false;
}
if (addon.product && isProductRestricted(addon.product)) {
return false;
}
return true;
});
}); });
const onClickAddOtherProduct = () => { const onClickAddOtherProduct = () => {
if (!canSelectAdditionalItems.value) { pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
pos.views.additionalItemSelection.value = false; if (pos.views.additionalItemSelection.value) {
scheduleAdditionalSelectionProductsLoad();
}
};
const loadAdditionalSelectionProducts = async () => {
if (additionalSelectionLoading.value) {
return; return;
} }
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
} const requestId = ++latestAdditionalSelectionProductsRequestId;
additionalSelectionLoading.value = true;
try {
const parsedCustomerId = customer_id.value ? parseInt(String(customer_id.value), 10) : null;
const response = await SessionUser.objects.products.get.all({
category: ADDITIONAL_SERVICES_CATEGORY_ID,
department_id: SessionUser.functions.getDepartmentIdFromUrl(),
...(parsedCustomerId !== null ? { customer_id: parsedCustomerId } : {}),
final_price: true,
});
if (requestId !== latestAdditionalSelectionProductsRequestId) {
return;
}
additionalSelectionProducts.value = orderProducts([...(Array.isArray(response) ? response : [])]);
pos.transactionItems.updateTransactionPrices();
} catch (error) {
if (requestId === latestAdditionalSelectionProductsRequestId) {
console.warn("Unable to load additional POS products", error);
}
} finally {
if (requestId === latestAdditionalSelectionProductsRequestId) {
additionalSelectionLoading.value = false;
}
}
};
const scheduleAdditionalSelectionProductsLoad = () => {
window.setTimeout(loadAdditionalSelectionProducts, 0);
};
// Watch for changes in available additional items, to update the pos.transactionItems.additionalItems // Watch for changes in available additional items, to update the pos.transactionItems.additionalItems
watch(availableAdditionalItems, (newVal) => { watch(
newVal.forEach(addon => { availableAdditionalItems,
if (addon.quantity && addon.quantity > 0 && addon.product) { (newVal) => {
addon.product.quantity = addon.quantity; // Ensure product has correct quantity let removedRestrictedItem = false;
const selectedProducts = newVal
.filter((addon) => {
if (getAdditionalItemRestriction(addon).restricted) {
if (addon.quantity && addon.quantity > 0) {
removedRestrictedItem = true;
}
return false;
}
return Boolean(addon.quantity && addon.quantity > 0 && addon.product);
})
.map((addon) => ({
...addon.product!,
quantity: addon.quantity,
}));
if (removedRestrictedItem) {
showRestrictedItemsRemovedWarning();
} }
pos.transactionItems.setAdditionalItems(newVal.filter(a => a.quantity && a.quantity > 0).map(a => a.product!).filter((p): p is PosProduct => !!p) );
}); const currentItems = pos.transactionItems.additionalItems.value || [];
}, { deep: true }); const isSame =
currentItems.length === selectedProducts.length &&
currentItems.every(
(item: PosProduct, index: number) =>
Number(item?.id) === Number(selectedProducts[index]?.id) &&
Number(item?.quantity ?? 0) === Number(selectedProducts[index]?.quantity ?? 0)
);
if (isSame) {
return;
}
pos.transactionItems.setAdditionalItems(
selectedProducts.filter((product): product is PosProduct => Boolean(product))
);
},
{ deep: true }
);
// Watch for changes in pos.transactionItems.additionalItems to remove items with quantity 0 // Watch for changes in pos.transactionItems.additionalItems to remove items with quantity 0
watch(() => pos.transactionItems.additionalItems.value, (newVal) => { watch(
if (!newVal) return; () => pos.transactionItems.additionalItems.value,
if (newVal.length === 0) { (newVal) => {
availableAdditionalItems.value = []; if (!newVal) return;
// if (newVal.length === 0) {
return; if (availableAdditionalItems.value.length === 0) {
return;
}
availableAdditionalItems.value = [];
//
return;
}
// Prevent recursive loop by checking if availableAdditionalItems already matches newVal
const currentProductIds = availableAdditionalItems.value.map((a) => a.id);
const newProductIds = newVal.map((p) => p.id);
const isSame =
currentProductIds.length === newProductIds.length && currentProductIds.every((id) => newProductIds.includes(id));
if (isSame) return;
availableAdditionalItems.value = getAvailableAdditionalItems();
},
{ deep: true }
);
watch(
() => pos.views.additionalItemSelection.value,
(isOpen) => {
if (!isOpen) {
additionalSelectionLoading.value = false;
return;
}
scheduleAdditionalSelectionProductsLoad();
} }
// Prevent recursive loop by checking if availableAdditionalItems already matches newVal );
const currentProductIds = availableAdditionalItems.value.map(a => a.id);
const newProductIds = newVal.map(p => p.id); onMounted(() => {
const isSame = currentProductIds.length === newProductIds.length && currentProductIds.every(id => newProductIds.includes(id)); if (pos.views.additionalItemSelection.value) {
if (isSame) return; scheduleAdditionalSelectionProductsLoad();
availableAdditionalItems.value = getAvailableAdditionalItems(); }
}, { deep: true }); });
const onClickAddProduct = async (product: PosProduct) => { const onClickAddProduct = async (product: PosProduct) => {
if (isAdditionalItemRestricted(product)) { if (isAdditionalItemRestricted(product)) {
showRestrictedItemsRemovedWarning();
return; return;
} }
pos.transactionItems.addAdditionalItem(product); pos.transactionItems.addAdditionalItem(product);
@@ -202,25 +313,63 @@ const onClickAddProduct = async (product: PosProduct) => {
//if (pos.views.additionalItemSelection.value) { //if (pos.views.additionalItemSelection.value) {
// pos.views.additionalItemSelection.value = false; // pos.views.additionalItemSelection.value = false;
//} //}
} };
</script> </script>
<template> <template>
<div data-testid="pos-mobile-additional-items"> <div data-testid="pos-mobile-additional-items">
<!-- Minimal view, when not set as fullscreen view --> <div
<WhiteBoxCard :toggleable="canSelectAdditionalItems && pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0" v-if="customer_attributes_status === 'error'"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0" class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-3"
@toggle="onToggle" data-testid="pos-mobile-customer-restrictions-load-error"
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)" >
v-if="!pos.views.additionalItemSelection.value"> <span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard
:toggleable="
canSelectAdditionalItems &&
pos.transactionItems.additionalItems.value &&
pos.transactionItems.additionalItems.value.length == 0
"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="
pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0
? true
: pos.views.additionalItemSelection.value
"
v-if="!pos.views.additionalItemSelection.value"
>
<!-- Header --> <!-- Header -->
<template #header> <template #header>
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open"> <div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
<div class="card-header-title pos-mobile-additional-items-header__title" data-testid="pos-mobile-additional-items-header-title">{{ displayLabel }}</div> <div
<div class="card-header-icon pos-mobile-additional-items-header__icon" data-testid="pos-mobile-additional-items-header-icon"> class="card-header-title pos-mobile-additional-items-header__title"
data-testid="pos-mobile-additional-items-header-title"
>
{{ displayLabel }}
</div>
<div
class="card-header-icon pos-mobile-additional-items-header__icon"
data-testid="pos-mobile-additional-items-header-icon"
>
<!-- Right arrow, if there's no items, down arrow if there are items --> <!-- Right arrow, if there's no items, down arrow if there are items -->
<span class="icon"> <span class="icon">
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i> <i
v-if="
!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0
"
class="fas fa-angle-right"
></i>
<i v-else class="fas fa-angle-down"></i> <i v-else class="fas fa-angle-down"></i>
</span> </span>
</div> </div>
@@ -230,6 +379,13 @@ const onClickAddProduct = async (product: PosProduct) => {
<template #content> <template #content>
<!-- Suggested items --> <!-- Suggested items -->
<div> <div>
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-3"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<!-- No items added yet --> <!-- No items added yet -->
<div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0"> <div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0">
<p>{{ SessionUser.objects.global.language.no_additional_items }}</p> <p>{{ SessionUser.objects.global.language.no_additional_items }}</p>
@@ -238,7 +394,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</div> </div>
</template> </template>
<!-- Footer --> <!-- Footer -->
<template #footer v-if="canSelectAdditionalItems"> <template #footer>
<!-- Select other product button --> <!-- Select other product button -->
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct"> <a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
<span class="icon"> <span class="icon">
@@ -249,24 +405,54 @@ const onClickAddProduct = async (product: PosProduct) => {
</template> </template>
</WhiteBoxCard> </WhiteBoxCard>
<!-- Fullscreen view, when selecting other products --> <!-- Fullscreen view, when selecting other products -->
<template v-else-if="canSelectAdditionalItems"> <template v-else>
<div data-testid="pos-mobile-additional-items-selection"> <div data-testid="pos-mobile-additional-items-selection">
<!-- Categories of products --> <div class="pos-mobile-additional-items-categories">
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>--> <button
<!-- Buttons --> type="button"
<PosDepartmentStepMobileFixedBottomControl variant="pos-step"> class="button is-rounded is-small is-dark"
<!-- Floating Card --> :data-testid="`pos-mobile-category-${ADDITIONAL_SERVICES_CATEGORY_ID}`"
<PosDepartmentStepMobile2FloatingCart/> @click="scheduleAdditionalSelectionProductsLoad"
<!-- Next button --> >
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct"> Extras
<span class="pos-mobile-cta-content"> </button>
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span> </div>
<span class="pos-mobile-cta-value has-text-white"> <div class="pos-mobile-additional-items-products">
<i class="fas fa-arrow-right"></i> <div
v-if="additionalSelectionLoading"
class="pos-mobile-products-loading"
data-testid="pos-mobile-products-loading"
>
<span class="pos-mobile-products-loading__label">{{ SessionUser.objects.global.language.loading }}</span>
</div>
<template v-else>
<PosDepartmentStepMobile2CategoryProduct
v-for="product in additionalSelectionProducts"
:key="product.id"
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
:testId="`pos-mobile-product-${product.id}`"
:disabled="isAdditionalItemRestricted(product)"
:restrictionMessage="getAdditionalSelectionRestrictionMessage(product)"
@addProduct="onClickAddProduct(product)"
/>
</template>
</div>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Floating Card -->
<PosDepartmentStepMobile2FloatingCart />
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="pos-mobile-cta-value has-text-white">
<i class="fas fa-arrow-right"></i>
</span>
</span> </span>
</span> </PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileButtonNextStep> </PosDepartmentStepMobileFixedBottomControl>
</PosDepartmentStepMobileFixedBottomControl>
</div> </div>
</template> </template>
</div> </div>
@@ -291,4 +477,10 @@ const onClickAddProduct = async (product: PosProduct) => {
flex: 0 0 auto; flex: 0 0 auto;
margin-left: auto; margin-left: auto;
} }
.pos-mobile-additional-items-categories {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem 0;
}
</style> </style>
@@ -1,30 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import Plus from "@/components/viewport/elements/icons/Plus.vue";
import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue"; import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { Addon } from "../objects/PosAddon.vue"; import { Addon } from "../objects/PosAddon.vue";
import PosDepartmentStepMobile2CategoryProduct import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue"; import { useI18n } from "vue-i18n";
import { transactionItems } from "../objects/PosDepartmentStepMobileFlow.vue";
const { t } = useI18n();
const props = defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
default: "Add-ons", default: "Add-ons",
required: true required: true,
}, },
addons: { addons: {
type: Array as () => Addon[], type: Array as () => Addon[],
default: () => [] default: () => [],
}, },
compact: { compact: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
showPrices: { showPrices: {
type: Boolean, type: Boolean,
default: true default: true,
} },
}); });
const emit = defineEmits(["update:addons"]); const emit = defineEmits(["update:addons"]);
/** /**
@@ -44,7 +46,7 @@ const emit = defineEmits(["update:addons"]);
const getLabel = (addon: Addon) => { const getLabel = (addon: Addon) => {
return addon.name; return addon.name;
//return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`; //return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`;
} };
/** /**
* When double-clicking on the product, it will add 1 to the quantity * When double-clicking on the product, it will add 1 to the quantity
@@ -52,85 +54,127 @@ const getLabel = (addon: Addon) => {
const doubleClickTimeout = 500; // milliseconds const doubleClickTimeout = 500; // milliseconds
const doubleClickTimers = ref<{ [key: string]: number | null }>({}); const doubleClickTimers = ref<{ [key: string]: number | null }>({});
const getAddonKey = (addon: Addon) => String(addon.id ?? addon.product?.id ?? "");
const setAddonQuantity = (addon: Addon, quantity: number) => {
const nextQuantity = Number(quantity || 0);
if (nextQuantity > 0 && isAddonRestricted(addon)) {
return;
}
const nextAddons = props.addons.map((candidate) =>
getAddonKey(candidate) === getAddonKey(addon) ? { ...candidate, quantity: nextQuantity } : candidate
);
const primaryAddons = transactionItems.primaryItem.value?.addons;
if (
Array.isArray(primaryAddons) &&
primaryAddons.some((candidate) => getAddonKey(candidate) === getAddonKey(addon))
) {
transactionItems.primaryItem.value!.addons = nextAddons;
}
emit("update:addons", nextAddons);
};
/** /**
* Function to add a product to the add-ons list * Function to add a product to the add-ons list
* @param addon * @param addon
*/ */
const onAddProduct = (addon: Addon) => { const onAddProduct = (addon: Addon) => {
if (isAddonRestricted(addon)) {
setAddonQuantity(addon, 0);
return;
}
const addonKey = getAddonKey(addon);
// Check if the product was just added // Check if the product was just added
if (doubleClickTimers.value[addon.id]) { if (doubleClickTimers.value[addonKey]) {
// Double-click detected, clear the timeout // Double-click detected, clear the timeout
clearTimeout(doubleClickTimers.value[addon.id]); clearTimeout(doubleClickTimers.value[addonKey]);
doubleClickTimers.value[addon.id] = null; doubleClickTimers.value[addonKey] = null;
// Check if the product can have more quantity // Check if the product can have more quantity
if (addon.quantity >= addon.max && addon.max !== -1) { if (addon.quantity >= addon.max && addon.max !== -1) {
// If the product max is reached, it will not be added. // If the product max is reached, it will not be added.
return; return;
} }
addon.quantity = addon.quantity + 1; setAddonQuantity(addon, addon.quantity + 1);
emit('update:addons', [...props.addons])
// Set the timeout again to allow for the next double-click // Set the timeout again to allow for the next double-click
doubleClickTimers.value[addon.id] = window.setTimeout(() => { doubleClickTimers.value[addonKey] = window.setTimeout(() => {
doubleClickTimers.value[addon.id] = null; doubleClickTimers.value[addonKey] = null;
}, doubleClickTimeout); }, doubleClickTimeout);
return; return;
} }
// Single-click detected, add the product to the list // Single-click detected, add the product to the list
// If the product is already has a quantity, set it to 0 // If the product is already has a quantity, set it to 0
if (addon.quantity > 0) { if (addon.quantity > 0) {
addon.quantity = 0; setAddonQuantity(addon, 0);
emit('update:addons', [...props.addons])
return; return;
} }
addon.quantity = (addon.min > 0 ? addon.min : 1); const nextQuantity = addon.min > 0 ? addon.min : 1;
// Set a timeout to handle double-clicks. // Set a timeout to handle double-clicks.
// If the product max is reached, it will not be added. // If the product max is reached, it will not be added.
if (addon.quantity >= addon.max && addon.max !== -1) { if (nextQuantity >= addon.max && addon.max !== -1) {
// If the product max is reached, it will not be added. // If the product max is reached, it will not be added.
emit('update:addons', [...props.addons]) setAddonQuantity(addon, nextQuantity);
return; return;
} }
doubleClickTimers.value[addon.id] = window.setTimeout(() => { doubleClickTimers.value[addonKey] = window.setTimeout(() => {
doubleClickTimers.value[addon.id] = null; doubleClickTimers.value[addonKey] = null;
}, doubleClickTimeout); }, doubleClickTimeout);
emit('update:addons', [...props.addons]) setAddonQuantity(addon, nextQuantity);
} };
const sortAddonsByProductOrderPriority = (addons: Addon[]) => { const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
return addons.sort((a, b) => { return [...addons].sort((a, b) => {
const priorityA = a.product.order_priority || 0; const priorityA = a.product.order_priority || 0;
const priorityB = b.product.order_priority || 0; const priorityB = b.product.order_priority || 0;
return priorityA - priorityB; return priorityA - priorityB;
}); });
} };
const isAddonRestricted = (addon: Addon) => (addon as any)?.restricted === true;
const getAddonRestrictionMessage = (addon: Addon) => {
if (!isAddonRestricted(addon)) {
return "";
}
return (addon as any)?.restrictionMessageKey
? t((addon as any).restrictionMessageKey)
: t("pos.restrictions.product_not_allowed");
};
</script> </script>
<template> <template>
<template v-if="addons.length > 0"> <!-- If there are add-ons available --> <template v-if="addons.length > 0">
<!-- If there are add-ons available -->
<p class="custom-label">{{ props.label }}</p> <p class="custom-label">{{ props.label }}</p>
<div> <div>
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id"> <template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
<PosDepartmentStepMobile2CategoryProduct <PosDepartmentStepMobile2CategoryProduct
:compact="props.compact" :compact="props.compact"
:price="addon.price" :price="addon.price"
:label="getLabel(addon)" :label="getLabel(addon)"
:piktogram="addon.product.piktogram" :piktogram="addon.product.piktogram"
:testId="`pos-mobile-addon-${addon.product.id}`" :testId="`pos-mobile-addon-${addon.product.id}`"
:showPrices="props.showPrices" :showPrices="props.showPrices"
:disabled="isAddonRestricted(addon)"
:restrictionMessage="getAddonRestrictionMessage(addon)"
@addProduct="onAddProduct(addon)" @addProduct="onAddProduct(addon)"
:customButton="addon.quantity > 0"> :customButton="true"
<span class="custom-select-quantity-container"> >
<span class="custom-select-quantity-container">
<ControlSelectAmount <ControlSelectAmount
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`" :testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
@update:quantity="addon.quantity = $event; emit('update:addons', [...props.addons])" @update:quantity="setAddonQuantity(addon, $event)"
v-model:quantity="addon.quantity" :quantity="addon.quantity"
:max="addon.max" :max="addon.max"
:min="addon.min" :min="addon.min"
:disabled="isAddonRestricted(addon)"
:restrictionMessage="getAddonRestrictionMessage(addon)"
/> />
</span> </span>
</PosDepartmentStepMobile2CategoryProduct> </PosDepartmentStepMobile2CategoryProduct>
</template> </template>
</div> </div>
</template> </template>
</template> </template>
@@ -148,13 +192,11 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
width: 100%; width: 100%;
height: 100%; height: 100%;
/* Inside auto layout */ /* Inside auto layout */
flex: none; flex: none;
order: 1; order: 1;
align-self: stretch; align-self: stretch;
flex-grow: 0; flex-grow: 0;
} }
.custom-container-entry-container { .custom-container-entry-container {
/* Frame 47 */ /* Frame 47 */
@@ -171,14 +213,13 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
width: 100%; width: 100%;
height: 32px; height: 32px;
border-bottom: 1px solid #D9D9D9; border-bottom: 1px solid #d9d9d9;
/* Inside auto layout */ /* Inside auto layout */
flex: none; flex: none;
order: 0; order: 0;
align-self: stretch; align-self: stretch;
flex-grow: 0; flex-grow: 0;
} }
.custom-icon-container { .custom-icon-container {
/* + */ /* + */
@@ -210,7 +251,7 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
height: 19px; height: 19px;
/* rg-tx */ /* rg-tx */
font-family: 'Arial'; font-family: "Arial";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-size: 19px; font-size: 19px;
@@ -219,18 +260,16 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
color: #000000; color: #000000;
/* Inside auto layout */ /* Inside auto layout */
flex: none; flex: none;
order: 2; order: 2;
flex-grow: 0; flex-grow: 0;
} }
.custom-icon { .custom-icon {
/* + */ /* + */
/* Icon color */ /* Icon color */
fill: #FFFFFF; fill: #ffffff;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -240,7 +279,6 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
flex: none; flex: none;
order: 0; order: 0;
flex-grow: 0; flex-grow: 0;
} }
.custom-label { .custom-label {
/* Add-ons */ /* Add-ons */
@@ -248,7 +286,7 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
width: 60px; width: 60px;
height: 16px; height: 16px;
font-family: 'Arial'; font-family: "Arial";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
@@ -257,11 +295,9 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
color: #000000; color: #000000;
/* Inside auto layout */ /* Inside auto layout */
flex: none; flex: none;
order: 0; order: 0;
flex-grow: 0; flex-grow: 0;
} }
</style> </style>
@@ -3,6 +3,49 @@ import GenericTag from "@/components/viewport/page/templates/generic/graphics/Ge
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue"; import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {onBeforeMount } from "vue"; import {onBeforeMount } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { customer_id } from "@/components/shop/POSDepartmentProcess.vue";
import { orderProducts } from "@/components/shop/Products.vue";
let latestProductsRequestId = 0;
const fetchCategoryProducts = async (category: {id: number, name: string}, departmentId: number) => {
const requestId = ++latestProductsRequestId;
pos.productList.setLoading(true);
pos.productList.clear();
try {
const response = await SessionUser.objects.products.get.category(
category.id,
departmentId,
customer_id.value ? parseInt(String(customer_id.value)) : null,
true
);
if (requestId !== latestProductsRequestId) {
return;
}
orderProducts(response).forEach((result: any) => {
pos.productList.add(result);
});
pos.transactionItems.updateTransactionPrices();
} catch (error) {
if (requestId === latestProductsRequestId) {
console.warn("Unable to load POS products", error);
}
} finally {
if (requestId === latestProductsRequestId) {
pos.productList.setLoading(false);
}
}
};
const selectCategory = (category: {id: number, name: string}) => {
const nextCategory = { ...category };
pos.categories.select(nextCategory);
fetchCategoryProducts(nextCategory, SessionUser.functions.getDepartmentIdFromUrl());
};
// Function to fetch categories based on the department ID // Function to fetch categories based on the department ID
const props = defineProps({ const props = defineProps({
// Optional list of category IDs to restrict the displayed categories to // Optional list of category IDs to restrict the displayed categories to
@@ -44,14 +87,12 @@ const fetchCategories = async (departmentId: number) => {
// Select the category 6, if it exists, otherwise select the first category // Select the category 6, if it exists, otherwise select the first category
const category6 = pos.categories.get().find(cat => cat.id === 6); const category6 = pos.categories.get().find(cat => cat.id === 6);
if (category6) { if (category6) {
pos.productList.setLoading(true); selectCategory(category6);
pos.categories.select(category6);
return; return;
} }
if (pos.categories.get().length > 0) { if (pos.categories.get().length > 0) {
pos.productList.setLoading(true); selectCategory(pos.categories.get()[0]);
pos.categories.select(pos.categories.get()[0]);
return; return;
} }
} catch (error) { } catch (error) {
@@ -82,10 +123,15 @@ onBeforeMount(() => {
</div> </div>
</div> </div>
<div v-else class="columns is-mobile is-multiline is-gapless"> <div v-else class="columns is-mobile is-multiline is-gapless">
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`"> <div
class="column is-narrow"
v-for="category in pos.categories.get()"
:key="category.id"
:data-testid="`pos-mobile-category-${category.id}`"
@click="selectCategory(category)"
>
<GenericTag <GenericTag
:active="pos.categories.isCategorySelected(category)" :active="pos.categories.isCategorySelected(category)"
@click="pos.categories.select(category)"
class="custom-gap"> class="custom-gap">
{{ category.name }} {{ category.name }}
</GenericTag> </GenericTag>
@@ -3,6 +3,7 @@
import {getPicture} from "@/components/displays/department/pos/displays/Piktogrammer.vue"; import {getPicture} from "@/components/displays/department/pos/displays/Piktogrammer.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Plus from "@/components/viewport/elements/icons/Plus.vue"; import Plus from "@/components/viewport/elements/icons/Plus.vue";
import { BTooltip } from "buefy";
const props = defineProps({ const props = defineProps({
piktogram: { piktogram: {
@@ -37,12 +38,24 @@ const props = defineProps({
testId: { testId: {
type: String, type: String,
default: '', default: '',
},
disabled: {
type: Boolean,
default: false,
},
restrictionMessage: {
type: String,
default: "",
} }
}); });
const emit = defineEmits(["addProduct"]); const emit = defineEmits(["addProduct"]);
const onClick = () => { const onClick = () => {
if (props.disabled) {
return;
}
emit("addProduct", { emit("addProduct", {
piktogram: props.piktogram, piktogram: props.piktogram,
label: props.label, label: props.label,
@@ -69,31 +82,102 @@ const imageStyleCompact = {
</script> </script>
<template> <template>
<div style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;" class="mb-1"> <div
style="border-bottom: 1px solid #e5e5e5; padding: 10px 0"
class="mb-1"
:class="{ 'pos-mobile-category-product--disabled': props.disabled }"
>
<div class="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''"> <div class="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''">
<div class="column is-narrow" @click="onClick" style="cursor: pointer;"> <div class="column is-narrow">
<img :src="getPicture(props.piktogram)" :alt="props.label" class="product-image" <button
:style="props.compact ? imageStyleCompact : imageSize" type="button"
style="border-radius: 8px; object-fit: contain;" /> class="pos-mobile-category-product__image-button"
:disabled="props.disabled"
:style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }"
@click="onClick"
>
<img
:src="getPicture(props.piktogram)"
:alt="props.label"
class="product-image"
:style="props.compact ? imageStyleCompact : imageSize"
style="border-radius: 8px; object-fit: contain"
/>
</button>
</div> </div>
<div class="column" @click="onClick" style="cursor: pointer;" :data-testid="props.testId || undefined"> <div class="column pos-mobile-category-product__name-column">
<template v-if="!props.compact"> <BTooltip
<h1 class="title is-6">{{ props.label }}</h1> v-if="props.disabled && props.restrictionMessage"
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2> :label="props.restrictionMessage"
<h2 class="subtitle is-6" v-else>&nbsp;</h2> :triggers="['hover', 'focus', 'click']"
</template> multilined
<template v-else> position="is-top"
<h1 class="title is-6" style="font-size: small">{{ props.label }}</h1> type="is-dark"
<h2 class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2> append-to-body
<h2 class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</h2> >
</template> <span
class="pos-mobile-category-product__tooltip-trigger"
tabindex="0"
:data-testid="props.testId ? `${props.testId}-restriction-tooltip` : undefined"
>
<button
type="button"
class="pos-mobile-category-product__name-button"
:data-testid="props.testId || undefined"
disabled
>
<template v-if="!props.compact">
<span class="title is-6">{{ props.label }}</span>
<span class="subtitle is-6" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" v-else>&nbsp;</span>
</template>
<template v-else>
<span class="title is-6" style="font-size: small">{{ props.label }}</span>
<span class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</span>
</template>
</button>
</span>
</BTooltip>
<button
v-else
type="button"
class="pos-mobile-category-product__name-button"
:data-testid="props.testId || undefined"
@click="onClick"
>
<template v-if="!props.compact">
<span class="title is-6">{{ props.label }}</span>
<span class="subtitle is-6" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" v-else>&nbsp;</span>
</template>
<template v-else>
<span class="title is-6" style="font-size: small">{{ props.label }}</span>
<span class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</span>
</template>
</button>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<slot name="right"/> <slot name="right" />
<button class="custom-button-product" @click="onClick" v-if="!props.customButton" :style="props.compact ? imageStyleCompact : imageSize" v-show="false"> <button
<span class="custom-icon"> class="custom-button-product"
<Plus width="100%" height="100%" /> @click="onClick"
</span> v-if="!props.customButton"
:style="props.compact ? imageStyleCompact : imageSize"
v-show="false"
>
<span class="custom-icon">
<Plus width="100%" height="100%" />
</span>
</button> </button>
<slot v-else></slot> <slot v-else></slot>
</div> </div>
@@ -125,6 +209,33 @@ const imageStyleCompact = {
cursor: pointer; cursor: pointer;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
} }
.pos-mobile-category-product__image-button,
.pos-mobile-category-product__name-button {
appearance: none;
background: transparent;
border: 0;
color: inherit;
padding: 0;
text-align: left;
}
.pos-mobile-category-product__image-button:disabled,
.pos-mobile-category-product__name-button:disabled {
cursor: not-allowed;
}
.pos-mobile-category-product__name-column,
.pos-mobile-category-product__name-button,
.pos-mobile-category-product__tooltip-trigger {
display: block;
width: 100%;
}
.pos-mobile-category-product__name-button .title,
.pos-mobile-category-product__name-button .subtitle {
display: block;
}
/* On larger screens, make the button appropriately sized */ /* On larger screens, make the button appropriately sized */
@media (min-width: 768px) { @media (min-width: 768px) {
.custom-button-product { .custom-button-product {
@@ -156,4 +267,13 @@ const imageStyleCompact = {
.has-quantity .column .subtitle { .has-quantity .column .subtitle {
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
} }
.pos-mobile-category-product--disabled {
opacity: 0.72;
}
.pos-mobile-category-product--disabled.has-quantity,
.pos-mobile-category-product--disabled .has-quantity {
background-color: #f5f5f5;
}
</style> </style>
@@ -7,7 +7,7 @@ const items = computed(() => {
return [ return [
pos.transactionItems.primaryItem.value, pos.transactionItems.primaryItem.value,
...pos.transactionItems.additionalItems.value, ...pos.transactionItems.additionalItems.value,
] ].filter((item) => item && String(item.name ?? "").trim() !== "")
}); });
const isCollapsed = ref(true); const isCollapsed = ref(true);
const isAddonsVisible = ref(true); const isAddonsVisible = ref(true);
@@ -83,7 +83,7 @@ const itemNames = computed(() => {
</div> </div>
<!-- Product addons --> <!-- Product addons -->
<template v-for="addon in item.addons" :key="addon.id" v-if="item.addons && item.addons.length > 0 && isAddonsVisible"> <template v-for="addon in item.addons" :key="addon.id" v-if="item.addons && item.addons.length > 0 && isAddonsVisible">
<div class="columns is-mobile is-vcentered is-gapless m-0" v-if="addon?.quantity > 0"> <div class="columns is-mobile is-vcentered is-gapless m-0" v-if="addon?.quantity > 0 && addon?.restricted !== true">
<div class="column"><!-- Product addons --> <div class="column"><!-- Product addons -->
<p class="has-text-left"><small>{{addon.quantity}}x {{ addon.name }}</small></p> <p class="has-text-left"><small>{{addon.quantity}}x {{ addon.name }}</small></p>
</div> </div>
@@ -101,4 +101,4 @@ const itemNames = computed(() => {
max-height: 200px; /* Adjust the max height as needed */ max-height: 200px; /* Adjust the max height as needed */
overflow-y: auto; /* Enable vertical scrolling */ overflow-y: auto; /* Enable vertical scrolling */
} }
</style> </style>
@@ -3,16 +3,20 @@ import {watch, ref, computed} from "vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue"; import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobile2CategoryProduct import PosDepartmentStepMobile2CategoryProduct
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue"; from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import {PosCategory} from "@/components/displays/department/pos/steps/mobile/objects/PosCategory.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue"; import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import { department_id, customer_id, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue"; import {
customer_attributes,
customer_attributes_status,
getProductRestriction,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile2Addons import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue"; from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue"; import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import { orderProducts } from "@/components/shop/Products.vue"; import { useI18n } from "vue-i18n";
const emits = defineEmits(["addProduct"]); const emits = defineEmits(["addProduct"]);
const { t } = useI18n();
const props = defineProps({ const props = defineProps({
// To be able to count the products, providing the "addonList" object, paired with the "asAddons" boolean // To be able to count the products, providing the "addonList" object, paired with the "asAddons" boolean
// This should be the list of addons, not the complete product list // This should be the list of addons, not the complete product list
@@ -26,72 +30,12 @@ const props = defineProps({
required: false, required: false,
default: false, default: false,
}, },
restrictionContext: {
type: String,
default: "primary",
},
}); });
const slots = defineSlots(); const slots = defineSlots();
let latestProductsRequestId = 0;
// Function to fetch categories based on the department ID
// This function will be called when the component is mounted
const fetchProducts = async (category: PosCategory, departmentId: number) => {
const requestId = ++latestProductsRequestId;
pos.productList.setLoading(true);
pos.productList.clear();
try {
const response = await SessionUser.objects.products.get.category(
category.id,
departmentId,
(customer_id.value ? parseInt(customer_id.value) : null),
true
);
if (requestId !== latestProductsRequestId) {
return;
}
orderProducts(response).forEach((result: PosProduct) => {
pos.productList.add(result);
});
// TODO: Update prices of products based on customer pricing rules
pos.transactionItems.updateTransactionPrices();
} catch (error) {
if (requestId === latestProductsRequestId) {
console.warn("Unable to load POS products", error);
}
} finally {
if (requestId === latestProductsRequestId) {
pos.productList.setLoading(false);
}
}
};
// Function to fetch suggested products
const fetchSuggestedProducts = async () => {
// TODO: Actually use the vehicle registrations from the pos object
await SessionUser.request(
'/vehicle/product-suggestions',
'GET',
{
department_id: parseInt(department_id.value),
reg_1: pos.vehicles.vehicle_1?.value?.reg || null,
reg_2: pos.vehicles.vehicle_2?.value?.reg || null,
reg_3: pos.vehicles.vehicle_3?.value?.reg || null,
}
).then((response) => {
});
};
// Watch for changes in the selected category
watch(() => pos.categories.selection(), (newCategory) => {
if (newCategory) {
// If the category id is -1, get the suggested products
if (newCategory.id === -1) {
fetchSuggestedProducts();
} else {
fetchProducts(newCategory, parseInt(department_id.value));
}
}
});
/** As Addons **/ /** As Addons **/
const mergedAddons = ref<Addon[]>([]); const mergedAddons = ref<Addon[]>([]);
@@ -100,18 +44,21 @@ const mergeCountAddons = (products: PosProduct[], addons: Addon[] | undefined) =
const tmpAddons = <Addon[]>[]; // Temporary array to hold the merged addons const tmpAddons = <Addon[]>[]; // Temporary array to hold the merged addons
// Convert all products to addons (only non-restricted products) // Convert all products to addons (only non-restricted products)
for (let product of products) { for (let product of products) {
// Skip restricted products
if (isProductRestricted(product)) {
continue;
}
let tmpAddon = pos.transactionItems.convertProductToAddon(product); // Convert product to addon (count = 0) let tmpAddon = pos.transactionItems.convertProductToAddon(product); // Convert product to addon (count = 0)
const restriction = getProductRestrictionForRow(product);
if (restriction.restricted) {
(tmpAddon as any).restricted = true;
(tmpAddon as any).restrictionRule = restriction.rule;
(tmpAddon as any).restrictionMessageKey = restriction.messageKey;
tmpAddon.quantity = 0;
}
tmpAddons.push(tmpAddon); tmpAddons.push(tmpAddon);
} }
// Loop through the addons and update the count if the addon exists in the tmpAddons array // Loop through the addons and update the count if the addon exists in the tmpAddons array
if (addons) { if (addons) {
for (let addon of addons) { for (let addon of addons) {
let index = tmpAddons.findIndex(a => a.product.id === addon.product.id); let index = tmpAddons.findIndex(a => a.product.id === addon.product.id);
if (index !== -1) { if (index !== -1 && !(tmpAddons[index] as any).restricted) {
tmpAddons[index].quantity = addon.quantity; // Update the count tmpAddons[index].quantity = addon.quantity; // Update the count
} }
} }
@@ -120,16 +67,42 @@ const mergeCountAddons = (products: PosProduct[], addons: Addon[] | undefined) =
return mergedAddons.value; return mergedAddons.value;
}; };
// Recalculate the merged addons when the props.addons change // Recalculate the merged addons when the props.addons change
watch(() => props.addons, (newAddons) => { watch(
mergeCountAddons(pos.productList.get(), newAddons); [
}, { immediate: true }); () => props.addons,
() => pos.productList.list.value,
() => customer_attributes.value,
() => customer_attributes_status.value,
],
([newAddons]) => {
mergeCountAddons(pos.productList.get(), newAddons);
},
{ immediate: true, deep: true }
);
// Computed property to filter out restricted products const getProductRestrictionForRow = (product: PosProduct) => {
const filteredProducts = computed(() => { const restriction = getProductRestriction(product, props.restrictionContext === "standaloneAdditionalService"
return pos.productList.get().filter(product => !isProductRestricted(product)); ? { isStandaloneAdditionalService: true }
}); : {});
return restriction;
};
const getProductRestrictionMessage = (product: PosProduct) => {
const restriction = getProductRestrictionForRow(product);
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const onSelectProduct = (product: PosProduct) => {
if (getProductRestrictionForRow(product).restricted) {
return;
}
emits("addProduct", product);
};
const productListItems = computed(() => pos.productList.list.value || []);
const shouldShowLoadingState = computed(() => { const shouldShowLoadingState = computed(() => {
return pos.categories.loading.value || pos.productList.loading.value; return pos.categories.loading.value || (pos.productList.loading.value && productListItems.value.length === 0);
}); });
</script> </script>
@@ -147,15 +120,17 @@ const shouldShowLoadingState = computed(() => {
</div> </div>
<template v-else-if="pos.categories.isSelected()"> <template v-else-if="pos.categories.isSelected()">
<template v-if="!props.asAddons"> <template v-if="!props.asAddons">
<!-- Display of products, without a "basket" (filtered for customer restrictions) --> <!-- Display of products, without a "basket" -->
<template v-for="product in filteredProducts" :key="product.id"> <template v-for="product in productListItems" :key="product.id">
<PosDepartmentStepMobile2CategoryProduct <PosDepartmentStepMobile2CategoryProduct
@addProduct="emits('addProduct', product)" @addProduct="onSelectProduct(product)"
:price="product.price" :price="product.price"
:label="product.name" :label="product.name"
:piktogram="product.piktogram" :piktogram="product.piktogram"
:testId="`pos-mobile-product-${product.id}`" :testId="`pos-mobile-product-${product.id}`"
> :disabled="getProductRestrictionForRow(product).restricted"
:restrictionMessage="getProductRestrictionMessage(product)"
>
<template #right> <template #right>
<slot name="right" :product="product"/> <slot name="right" :product="product"/>
</template> </template>

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