Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e1552a47 |
@@ -1,50 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,66 +1,32 @@
|
||||
name: Qodana
|
||||
name: Qodana Configuration Upload
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [master, beta, canary, internal]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
push:
|
||||
branches: [master, beta, canary, internal]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
name: Qodana
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
upload-qodana-config:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
# v5.0.1
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Require Qodana project token
|
||||
shell: bash
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN:-}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Qodana
|
||||
# v2026.1.3
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
||||
with:
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
upload-result: false
|
||||
use-annotations: true
|
||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
||||
post-pr-comment: true
|
||||
github-token: ${{ github.token }}
|
||||
push-fixes: none
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
docker run --rm \
|
||||
-v "$(pwd):/workspace" \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
|
||||
@@ -11,36 +11,6 @@ on:
|
||||
description: Store build number/version code
|
||||
required: false
|
||||
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:
|
||||
tags:
|
||||
- "mobile-v*"
|
||||
@@ -56,81 +26,40 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
|
||||
group: mobile-store-artifacts-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android AAB and Play upload
|
||||
name: Android AAB
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(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: ubuntu-24.04
|
||||
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 }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -138,11 +67,10 @@ jobs:
|
||||
sdkmanager "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
- name: Resolve mobile version
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_name="$INPUT_VERSION_NAME"
|
||||
@@ -156,22 +84,10 @@ jobs:
|
||||
echo "MOBILE_VERSION_NAME=$version_name" >> "$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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Decode Android signing key
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -180,6 +96,10 @@ jobs:
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
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"
|
||||
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"
|
||||
@@ -188,98 +108,48 @@ jobs:
|
||||
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sync Android shell
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
npm run mobile:android:sync
|
||||
npm run mobile:permissions:check
|
||||
npm run mobile:android:signing:check
|
||||
|
||||
- name: Build signed Android App Bundle
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
working-directory: android
|
||||
run: ./gradlew --no-daemon bundleRelease
|
||||
|
||||
- name: Verify Android App Bundle signature
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
||||
run: jarsigner -verify -certs -verbose android/app/build/outputs/bundle/release/app-release.aab >/dev/null
|
||||
|
||||
- name: Upload Android artifact
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
path: ${{ env.ANDROID_AAB_PATH }}
|
||||
path: android/app/build/outputs/bundle/release/app-release.aab
|
||||
if-no-files-found: error
|
||||
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:
|
||||
name: iOS IPA and App Store upload
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(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 }}
|
||||
name: iOS IPA
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
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"
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Resolve mobile version
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_name="$INPUT_VERSION_NAME"
|
||||
@@ -293,32 +163,16 @@ jobs:
|
||||
echo "MOBILE_VERSION_NAME=$version_name" >> "$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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build and sync iOS shell
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
npm run build
|
||||
npx cap sync ios
|
||||
npm run mobile:permissions:check
|
||||
|
||||
- name: Install Apple signing assets
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
|
||||
@@ -328,6 +182,12 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
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"
|
||||
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
|
||||
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
|
||||
@@ -354,35 +214,14 @@ jobs:
|
||||
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
|
||||
run: xcodebuild -resolvePackageDependencies -project ios/App/App.xcodeproj -scheme App
|
||||
|
||||
- name: Archive iOS app
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
xcodebuild \
|
||||
-project "$IOS_PROJECT_PATH" \
|
||||
-scheme "$IOS_SCHEME" \
|
||||
-project ios/App/App.xcodeproj \
|
||||
-scheme App \
|
||||
-configuration Release \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
|
||||
@@ -395,7 +234,6 @@ jobs:
|
||||
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
|
||||
|
||||
- name: Export iOS IPA
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -417,7 +255,7 @@ jobs:
|
||||
<string>$APPLE_TEAM_ID</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>$IOS_BUNDLE_ID</key>
|
||||
<key>io.truckwash.app</key>
|
||||
<string>$IOS_PROFILE_NAME</string>
|
||||
</dict>
|
||||
<key>stripSwiftSymbols</key>
|
||||
@@ -432,43 +270,15 @@ jobs:
|
||||
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
|
||||
-exportPath "$RUNNER_TEMP/ios-export" \
|
||||
-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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/ios-export/*.ipa
|
||||
if-no-files-found: error
|
||||
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
|
||||
if: always()
|
||||
shell: bash
|
||||
@@ -479,6 +289,3 @@ jobs:
|
||||
if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then
|
||||
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
|
||||
fi
|
||||
if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then
|
||||
rm -f "$APP_STORE_CONNECT_API_KEY_PATH"
|
||||
fi
|
||||
|
||||
@@ -4,43 +4,8 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "What to run for a manual dispatch."
|
||||
required: true
|
||||
type: choice
|
||||
default: full
|
||||
options:
|
||||
- full
|
||||
- targeted
|
||||
- targeted-then-full
|
||||
target_specs:
|
||||
description: "Comma- or newline-separated Playwright spec paths under tests/e2e."
|
||||
required: false
|
||||
type: string
|
||||
default: "tests/e2e/superuser-department-overview.spec.js"
|
||||
target_projects:
|
||||
description: "JSON array of Playwright projects for targeted mode."
|
||||
required: false
|
||||
type: string
|
||||
default: '["chromium-desktop","chromium-mobile","chromium-tablet","webkit-mobile","webkit-desktop"]'
|
||||
target_grep:
|
||||
description: "Optional Playwright grep pattern for targeted mode."
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
runner:
|
||||
description: "Runner pool for this manually dispatched test run"
|
||||
required: false
|
||||
default: "self-hosted"
|
||||
type: choice
|
||||
options:
|
||||
- self-hosted
|
||||
- github-hosted
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
@@ -48,22 +13,16 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Repository variables used as CI runner and credit controls:
|
||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
||||
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
|
||||
jobs:
|
||||
format-tests:
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -96,11 +55,10 @@ jobs:
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -139,206 +97,23 @@ jobs:
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
e2e-targeted:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
(inputs.mode == 'targeted' || inputs.mode == 'targeted-then-full')
|
||||
needs: build-and-unit
|
||||
name: E2E-targeted-${{ matrix.project }}
|
||||
# Use GitHub-hosted runners to avoid self-hosted desktop contention and sleep/power events.
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 35
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project: ${{ fromJSON(inputs.target_projects || '["chromium-desktop"]') }}
|
||||
env:
|
||||
MATRIX_PROJECT: ${{ matrix.project }}
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-targeted-${{ matrix.project }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: on-first-retry
|
||||
TARGET_GREP: ${{ inputs.target_grep }}
|
||||
TARGET_SPECS: ${{ inputs.target_specs }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
steps:
|
||||
- name: Normalize workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run targeted Playwright specs in container
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_PROJECT" in
|
||||
chromium-mobile) project_offset=1 ;;
|
||||
chromium-desktop) project_offset=2 ;;
|
||||
chromium-tablet) project_offset=3 ;;
|
||||
webkit-mobile) project_offset=31 ;;
|
||||
webkit-desktop) project_offset=32 ;;
|
||||
webkit-tablet) project_offset=33 ;;
|
||||
firefox-mobile) project_offset=61 ;;
|
||||
firefox-desktop) project_offset=62 ;;
|
||||
firefox-tablet) project_offset=63 ;;
|
||||
*) echo "Unsupported Playwright project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + project_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
|
||||
playwright_port_lock=""
|
||||
playwright_dev_port=""
|
||||
for ((candidate = port_seed; candidate < port_seed + 1000; candidate += 1)); do
|
||||
lock_dir="${lock_root}/${candidate}.lock"
|
||||
if ! mkdir "$lock_dir" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
if ss -H -ltn "sport = :${candidate}" 2>/dev/null | grep -q .; then
|
||||
rmdir "$lock_dir" || true
|
||||
continue
|
||||
fi
|
||||
playwright_port_lock="$lock_dir"
|
||||
playwright_dev_port="$candidate"
|
||||
break
|
||||
done
|
||||
if [[ -z "$playwright_dev_port" ]]; then
|
||||
echo "Unable to find a free Playwright dev-server port." >&2
|
||||
exit 1
|
||||
fi
|
||||
trap 'if [[ -n "${playwright_port_lock:-}" ]]; then rmdir "$playwright_port_lock" 2>/dev/null || true; fi' EXIT
|
||||
if docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(docker)
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(sudo docker)
|
||||
else
|
||||
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
scripts/ci/runner-diagnostics.sh "before targeted Playwright ${MATRIX_PROJECT}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend targeted Playwright ${MATRIX_PROJECT}" \
|
||||
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
--workdir /work \
|
||||
--env HOME=/tmp \
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
|
||||
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||
--env TARGET_GREP="$TARGET_GREP" \
|
||||
--env TARGET_SPECS="$TARGET_SPECS" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
|
||||
git config --global --add safe.directory /work
|
||||
install_dependencies() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
if npm ci --legacy-peer-deps --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" == "3" ]]; then
|
||||
return 1
|
||||
fi
|
||||
echo "npm ci failed on attempt ${attempt}; retrying..." >&2
|
||||
sleep 20
|
||||
done
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
mapfile -t spec_args < <(printf "%s\n" "$TARGET_SPECS" | tr "," "\n" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;/^$/d")
|
||||
if [[ "${#spec_args[@]}" -eq 0 && -z "${TARGET_GREP:-}" ]]; then
|
||||
echo "Provide at least one spec path or grep pattern." >&2
|
||||
exit 1
|
||||
fi
|
||||
for spec_path in "${spec_args[@]}"; do
|
||||
if [[ "$spec_path" == /* || "$spec_path" == *".."* || "$spec_path" != tests/e2e/* ]]; then
|
||||
echo "Targeted spec must stay under tests/e2e: $spec_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$spec_path" ]]; then
|
||||
echo "Targeted spec does not exist: $spec_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
args=("${spec_args[@]}")
|
||||
if [[ -n "${TARGET_GREP:-}" ]]; then
|
||||
args+=(--grep "$TARGET_GREP")
|
||||
fi
|
||||
args+=(--project="$MATRIX_PROJECT")
|
||||
npx playwright test "${args[@]}"
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
run: scripts/ci/runner-diagnostics.sh "after targeted Playwright ${{ matrix.project }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-targeted-${{ matrix.project }}
|
||||
path: |
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
e2e-pr:
|
||||
if: >
|
||||
always() &&
|
||||
github.event_name != 'schedule' &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||
(
|
||||
github.event_name != 'workflow_dispatch' ||
|
||||
inputs.mode == 'full' ||
|
||||
needs.e2e-targeted.result == 'success'
|
||||
)
|
||||
needs: [build-and-unit, e2e-targeted]
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
timeout-minutes: 45
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
suite: [core, changed]
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: on-first-retry
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -365,29 +140,20 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zero_sha="0000000000000000000000000000000000000000"
|
||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
head_ref="$PR_HEAD_SHA"
|
||||
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
|
||||
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
|
||||
base_ref="origin/$DEFAULT_BRANCH"
|
||||
head_ref="$HEAD_SHA"
|
||||
else
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
head_ref="$HEAD_SHA"
|
||||
fi
|
||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
|
||||
head_ref="$PR_HEAD_SHA"
|
||||
else
|
||||
head_ref="$HEAD_SHA"
|
||||
fi
|
||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
@@ -415,9 +181,8 @@ jobs:
|
||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
|
||||
playwright_port_lock=""
|
||||
playwright_dev_port=""
|
||||
@@ -458,8 +223,6 @@ jobs:
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
|
||||
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||
@@ -486,7 +249,6 @@ jobs:
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
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"
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
else
|
||||
@@ -511,50 +273,19 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
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:
|
||||
if: >
|
||||
always() &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
||||
(
|
||||
github.event_name != 'workflow_dispatch' ||
|
||||
inputs.mode == 'full' ||
|
||||
needs.e2e-targeted.result == 'success'
|
||||
)
|
||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
|
||||
needs: [build-and-unit, e2e-pr]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
browser: [chromium, webkit, firefox]
|
||||
device: [mobile, desktop, tablet]
|
||||
@@ -576,7 +307,6 @@ jobs:
|
||||
PLAYWRIGHT_VIDEO_MODE: off
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
@@ -627,9 +357,8 @@ jobs:
|
||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
|
||||
playwright_port_lock=""
|
||||
playwright_dev_port=""
|
||||
|
||||
@@ -15,7 +15,6 @@ dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
dev-dist
|
||||
.playwright-cli/
|
||||
|
||||
# Mobile build and signing outputs
|
||||
/app/build/
|
||||
|
||||
@@ -16,16 +16,6 @@ See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
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
|
||||
|
||||
```sh
|
||||
@@ -164,46 +154,6 @@ Artifacts and summaries:
|
||||
- `output/playwright/test-lists/<project>-<role>.txt`
|
||||
- `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
|
||||
|
||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0787BB</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -1,102 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,41 +1,23 @@
|
||||
# Mobile Store Artifacts
|
||||
|
||||
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.
|
||||
The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor.
|
||||
|
||||
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
|
||||
|
||||
- Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name`, `version_code`, upload toggles, and Android track/status overrides.
|
||||
- Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name` and `version_code`.
|
||||
- Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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_KEYSTORE_BASE64`
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
|
||||
|
||||
iOS:
|
||||
|
||||
@@ -44,11 +26,6 @@ iOS:
|
||||
- `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 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
|
||||
|
||||
@@ -80,21 +57,6 @@ The signed Android bundle is written to:
|
||||
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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="" class="theme-light tw-bootstrap-loading" data-theme="light">
|
||||
<html lang="" class="theme-light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script>
|
||||
(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 href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
|
||||
var base = document.createElement('base');
|
||||
@@ -24,437 +21,9 @@
|
||||
<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" />
|
||||
<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>
|
||||
<body class="tw-bootstrap-loading">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/releaseBootstrap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2415,12 +2415,6 @@ paths:
|
||||
type: string
|
||||
description: Contact person name
|
||||
example: "Mikkel"
|
||||
ean:
|
||||
type: string
|
||||
description: Optional EAN used for e-invoicing in e-conomic
|
||||
maxLength: 13
|
||||
pattern: '^[0-9]{1,13}$'
|
||||
example: "5790001234567"
|
||||
g_recaptcha_response:
|
||||
type: string
|
||||
description: reCAPTCHA verification token
|
||||
@@ -8195,12 +8189,6 @@ paths:
|
||||
email: {type: string}
|
||||
phone: {type: integer}
|
||||
name: {type: string}
|
||||
ean:
|
||||
type: string
|
||||
description: Optional EAN used for e-invoicing in e-conomic
|
||||
maxLength: 13
|
||||
pattern: '^[0-9]{1,13}$'
|
||||
example: "5790001234567"
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"@creativebulma/bulma-divider": "^1.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@event-calendar/core": "^4.1.0",
|
||||
"@playwright/experimental-ct-vue": "^1.58.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/event-calendar__core": "^3.7.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
@@ -80,7 +79,6 @@
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jimp": "0.22.12",
|
||||
"jsdom": "^29.0.0",
|
||||
"otpauth": "^9.5.0",
|
||||
"prettier": "2.8.8",
|
||||
@@ -4220,229 +4218,6 @@
|
||||
"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": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
|
||||
@@ -47,25 +47,19 @@
|
||||
"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: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:update-server-version": "node scripts/release/update-server-version.mjs",
|
||||
"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:all": "npm run test:unit && npm run test:ct:pr && npm run test:e2e:pr",
|
||||
"test:all": "npm run test:unit && npm run test:e2e:pr",
|
||||
"twa:build": "bubblewrap build",
|
||||
"twa:update": "bubblewrap update",
|
||||
"mobile:sync": "npm run build && npx cap sync",
|
||||
"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:android:sync": "npm run build && npx cap sync android",
|
||||
"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: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:play-upload": "node scripts/mobile/upload-google-play.mjs",
|
||||
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
||||
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
|
||||
},
|
||||
@@ -132,7 +126,6 @@
|
||||
"@creativebulma/bulma-divider": "^1.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@event-calendar/core": "^4.1.0",
|
||||
"@playwright/experimental-ct-vue": "^1.58.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/event-calendar__core": "^3.7.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
@@ -142,7 +135,6 @@
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jimp": "0.22.12",
|
||||
"jsdom": "^29.0.0",
|
||||
"otpauth": "^9.5.0",
|
||||
"prettier": "2.8.8",
|
||||
|
||||
@@ -40,7 +40,7 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/release/**", "**/quarantine/**"],
|
||||
testIgnore: ["**/release/**"],
|
||||
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
|
||||
timeout: 60_000,
|
||||
fullyParallel: true,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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": "/"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,12 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 33 KiB |
@@ -1,22 +1,46 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# Qodana analysis is configured by qodana.yaml file #
|
||||
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||
#-------------------------------------------------------------------------------#
|
||||
|
||||
#################################################################################
|
||||
# WARNING: Do not store sensitive information in this file, #
|
||||
# as its contents will be included in the Qodana report. #
|
||||
#################################################################################
|
||||
version: "1.0"
|
||||
linter: jetbrains/qodana-js:2026.1
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
profile:
|
||||
name: qodana.recommended
|
||||
name: qodana.starter
|
||||
|
||||
bootstrap: npm ci --legacy-peer-deps
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
|
||||
include:
|
||||
- name: Eslint
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
|
||||
exclude:
|
||||
- name: All
|
||||
paths:
|
||||
- src/i18n/generated
|
||||
- node_modules.codex-backup
|
||||
- output
|
||||
- .gradle
|
||||
- playwright/.cache
|
||||
- android
|
||||
- ios
|
||||
- app
|
||||
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||
#bootstrap: sh ./prepare-qodana.sh
|
||||
|
||||
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||
#plugins:
|
||||
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||
|
||||
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
|
||||
# severityThresholds - configures maximum thresholds for different problem severities
|
||||
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
|
||||
# Code Coverage is available in Ultimate and Ultimate Plus plans
|
||||
#failureConditions:
|
||||
# severityThresholds:
|
||||
# any: 15
|
||||
# critical: 5
|
||||
# testCoverageThresholds:
|
||||
# fresh: 70
|
||||
# total: 50
|
||||
|
||||
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||
linter: jetbrains/qodana-js:2025.3
|
||||
|
||||
@@ -275,24 +275,7 @@ const transformLocale = (locale, originalMessages) => {
|
||||
(entry) => normalizeToken(entry.value) === normalized
|
||||
);
|
||||
if (existingWordEntry) {
|
||||
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);
|
||||
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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.");
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/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;
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -7,18 +7,14 @@ export const fallbackChangePatterns = [
|
||||
/^vite\.config\.js$/u,
|
||||
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
|
||||
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
|
||||
/^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u,
|
||||
/^scripts\/run-playwright-(?:pr|ci-parallel|batched-chromium)\.mjs$/u,
|
||||
/^tests\/e2e\/(?:support|fixtures)\//u,
|
||||
];
|
||||
|
||||
export const sourceMappings = [
|
||||
{
|
||||
name: "auth",
|
||||
patterns: [
|
||||
/^src\/(?:views|components|middleware)\/.*auth/iu,
|
||||
/^src\/views\/auth\//u,
|
||||
/^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u,
|
||||
],
|
||||
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
|
||||
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
@@ -28,70 +24,15 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/navigation.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "limited-backoffice",
|
||||
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"],
|
||||
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",
|
||||
patterns: [
|
||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/RolePermissionManager\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-users",
|
||||
patterns: [/^src\/views\/dashboards\/superUserDashboard\/user\//u],
|
||||
specs: ["tests/e2e/superuser-users.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-dashboard",
|
||||
patterns: [
|
||||
/^src\/views\/dashboards\/superUserDashboard\/SuperUserDashboard(?:Navigation)?\.vue$/u,
|
||||
/^src\/components\/displays\/superuser\/system\//u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-department-overview",
|
||||
patterns: [
|
||||
/^src\/services\/superuserDepartmentOverview\.js$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-department-overview.spec.js", "tests/e2e/superuser-department-employees.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "header-navigation",
|
||||
patterns: [
|
||||
/^src\/components\/viewport\/page\/headers\//u,
|
||||
/^src\/components\/models\/navigation\/items\/NavigationMenuItemsGlobal\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/navigation.smoke.spec.js", "tests/e2e/limited-backoffice.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "booking",
|
||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||
@@ -108,49 +49,10 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/userVehicles.spec.ts"],
|
||||
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",
|
||||
patterns: [
|
||||
/(?:^|[/_.-])pos(?:[/_.-]|$)/iu,
|
||||
/(?:^|\/)(?:POS|Pos)[A-Z][^/]*\.(?:vue|js|ts)$/u,
|
||||
/^src\/assets\/pos\.css$/u,
|
||||
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
|
||||
/^src\/features\/customer\/customerProductRules\.js$/u,
|
||||
/^src\/services\/economicCustomerIdentifiers\.js$/u,
|
||||
],
|
||||
specs: [
|
||||
"tests/e2e/pos-flow.spec.js",
|
||||
"tests/e2e/pos-mobile-order-flow.spec.js",
|
||||
"tests/e2e/admin-pos-orders.spec.ts",
|
||||
"tests/e2e/pos-customer-rules.spec.js",
|
||||
],
|
||||
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
@@ -162,28 +64,12 @@ export const sourceMappings = [
|
||||
{
|
||||
name: "admin-department-notifications",
|
||||
patterns: [
|
||||
/^src\/views\/dashboards\/departmentDashboard\/modules\/notifications\/DepartmentNotifications\.vue$/u,
|
||||
/^src\/components\/displays\/department\/notifications\//u,
|
||||
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "admin-daily-report",
|
||||
patterns: [
|
||||
/^src\/views\/dashboards\/departmentDashboard\/modules\/daily-report\//u,
|
||||
/^src\/components\/session\/token\/SessionUser\/Objects\/DepartmentDailyReports\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/admin-daily-report.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "limited-backoffice",
|
||||
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
|
||||
specs: ["tests/e2e/limited-backoffice.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "invoicing",
|
||||
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
|
||||
@@ -202,47 +88,10 @@ export const sourceMappings = [
|
||||
},
|
||||
{
|
||||
name: "system-status",
|
||||
patterns: [/system[-/]?status/iu, /system(?:Database|Redis|Minio)/iu, /SystemDependencyDisplay/iu, /replication/iu],
|
||||
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
|
||||
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
||||
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",
|
||||
patterns: [
|
||||
/^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,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
|
||||
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",
|
||||
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
|
||||
|
||||
@@ -20,7 +20,6 @@ export const ownedFilesByRole = {
|
||||
"auth.smoke.spec.js",
|
||||
"booking-selfserve.smoke.spec.js",
|
||||
"connectivityIssue.spec.ts",
|
||||
"date-period-selector.smoke.spec.js",
|
||||
"example.spec.ts",
|
||||
"guest-book-wash-mobile.spec.ts",
|
||||
"i18n-v2-integrity.spec.ts",
|
||||
@@ -62,7 +61,6 @@ export const ownedFilesByRole = {
|
||||
"admin-department-visibility.spec.ts",
|
||||
"admin-overview-mobile.spec.ts",
|
||||
"admin-overview-night-washes.spec.ts",
|
||||
"admin-pos-order-filters.spec.ts",
|
||||
"admin-pos-drafts.spec.ts",
|
||||
"admin-pos-orders.spec.ts",
|
||||
"adminModuleGoals.spec.ts",
|
||||
@@ -81,6 +79,7 @@ export const ownedFilesByRole = {
|
||||
"pos.visual.spec.js",
|
||||
],
|
||||
superuser: [
|
||||
"coolify-infrastructure.spec.js",
|
||||
"edge-gateways.fleet-outline.spec.js",
|
||||
"edge-gateways.routes.spec.js",
|
||||
"edge-gateways.smoke.spec.js",
|
||||
@@ -99,23 +98,15 @@ export const ownedFilesByRole = {
|
||||
"self-serve-studio-flow.spec.js",
|
||||
"session-bootstrap.spec.ts",
|
||||
"superuser-bookings.spec.ts",
|
||||
"superuser-cron.spec.ts",
|
||||
"superuser-customer-rules.spec.ts",
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
"superuser-department-branding.spec.js",
|
||||
"superuser-department-shells.spec.js",
|
||||
"superuser-department-overview.spec.js",
|
||||
"superuser-department-employees.spec.ts",
|
||||
"superuser-department-gates.spec.ts",
|
||||
"superuser-department-lanes.spec.ts",
|
||||
"superuser-department-pricing-custom-only.spec.ts",
|
||||
"superuser-departments-archive.spec.ts",
|
||||
"superuser-drafts.spec.ts",
|
||||
"superuser-orders-date-filters.spec.ts",
|
||||
"superuser-products-layout.spec.ts",
|
||||
"superuser-roles-permissions.spec.ts",
|
||||
"superuser-security.spec.ts",
|
||||
"superuser-system-status.smoke.spec.js",
|
||||
"superuser-users.spec.ts",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
|
||||
@@ -2,13 +2,7 @@ import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 workingDirectory = process.cwd();
|
||||
@@ -251,7 +245,7 @@ function addSpec(selection, spec, projects) {
|
||||
}
|
||||
|
||||
function isE2eSpec(file) {
|
||||
return /^tests\/e2e\/(?!quarantine\/).+\.spec\.(?:js|ts)$/u.test(file);
|
||||
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
|
||||
}
|
||||
|
||||
function shouldFallback(file) {
|
||||
@@ -267,8 +261,6 @@ function selectChangedTests(changedFiles) {
|
||||
specProjects: new Map(),
|
||||
mappedFiles: [],
|
||||
unmappedFiles: [],
|
||||
directSpecFiles: [],
|
||||
skippedDirectSpecFiles: [],
|
||||
fallback: false,
|
||||
};
|
||||
|
||||
@@ -278,7 +270,8 @@ function selectChangedTests(changedFiles) {
|
||||
const file = normalizePath(rawFile);
|
||||
|
||||
if (isE2eSpec(file)) {
|
||||
selection.directSpecFiles.push(file);
|
||||
addSpec(selection, file, selectedProjects);
|
||||
selection.mappedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -302,26 +295,13 @@ function selectChangedTests(changedFiles) {
|
||||
|
||||
selection.mappedFiles.push(file);
|
||||
for (const mapping of matches) {
|
||||
const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects;
|
||||
const projects = mappedProjects.filter((project) => selectedProjects.includes(project));
|
||||
if (projects.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
|
||||
for (const spec of mapping.specs) {
|
||||
addSpec(selection, spec, projects);
|
||||
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.specProjects.size === 0 && !selection.fallback) {
|
||||
for (const file of selection.directSpecFiles) {
|
||||
addSpec(selection, file, selectedProjects);
|
||||
selection.mappedFiles.push(file);
|
||||
}
|
||||
} else {
|
||||
selection.skippedDirectSpecFiles.push(...selection.directSpecFiles);
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -367,9 +347,7 @@ async function runChangedSelection(selection) {
|
||||
|
||||
if (selection.fallback) {
|
||||
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) {
|
||||
const code = await runPlaywright({
|
||||
@@ -392,14 +370,6 @@ async function runChangedSelection(selection) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (selection.skippedDirectSpecFiles.length > 0) {
|
||||
console.log(
|
||||
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, group] of groups.entries()) {
|
||||
for (const project of group.projects) {
|
||||
const code = await runPlaywright({
|
||||
@@ -476,9 +446,7 @@ async function main() {
|
||||
|
||||
const changed = await getChangedFiles();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import favicon from "@/assets/favicon.ico";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
|
||||
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
|
||||
import {
|
||||
releaseChannelSwitchedStatus,
|
||||
releaseChannelUnavailableStatus,
|
||||
@@ -17,20 +16,6 @@ import {
|
||||
const route = useRoute();
|
||||
const { t, te, locale } = useI18n({ useScope: "global" });
|
||||
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 DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
||||
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
||||
@@ -96,42 +81,6 @@ const shouldRenderReleaseChannelSwitched = computed(
|
||||
const shouldHideGlobalOverlays = computed(
|
||||
() => 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 () => {
|
||||
if (!hasStoredSessionToken()) {
|
||||
@@ -162,50 +111,22 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suspense :timeout="0">
|
||||
<template #default>
|
||||
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
|
||||
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
|
||||
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Suspense :timeout="0">
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
|
||||
</template>
|
||||
</Suspense>
|
||||
</router-view>
|
||||
</LayoutV2>
|
||||
<main v-else-if="route.meta?.template === 'clear-main'">
|
||||
<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>
|
||||
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
|
||||
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
|
||||
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
|
||||
<router-view />
|
||||
</LayoutV2>
|
||||
<main v-else-if="route.meta?.template === 'clear-main'">
|
||||
<router-view />
|
||||
</main>
|
||||
<template v-else>
|
||||
<header></header>
|
||||
<main>
|
||||
<DefaultPageWrapper>
|
||||
<router-view />
|
||||
</DefaultPageWrapper>
|
||||
</main>
|
||||
</template>
|
||||
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||
<FrontendMaintenanceMenu />
|
||||
|
||||
@@ -48,24 +48,7 @@ textarea.has-sharp-edges {
|
||||
--bulma-box-shadow: none !important;
|
||||
--bulma-card-shadow: none !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;
|
||||
--bulma-skeleton-background: hsla(197 100% 35% / 0.70) !important;
|
||||
}
|
||||
|
||||
/*:root {*/
|
||||
@@ -121,16 +104,16 @@ body:not(.pleno-large-table-headers) .table thead th {
|
||||
margin-top: 30px !important;
|
||||
}
|
||||
|
||||
.cell .card-content .field {
|
||||
.cell .card-content .field{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cell .card-content .field .label {
|
||||
.cell .card-content .field .label{
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.mb-8-mobile {
|
||||
.mb-8-mobile{
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
}
|
||||
@@ -142,4 +125,4 @@ body:not(.pleno-large-table-headers) .table thead th {
|
||||
.mt-30-tablet {
|
||||
margin-top: 30px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
<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>
|
||||
@@ -1,252 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.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 {IS_DEV} from "@/config.js";
|
||||
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
form_identifier: {
|
||||
@@ -105,6 +106,28 @@ 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 randomElementId = Math.random().toString(36).substring(7)
|
||||
const fieldErrors = ref([]);
|
||||
@@ -496,6 +519,10 @@ SessionUser.objects.forms.get.single(props.form_identifier).then((response) => {
|
||||
}
|
||||
fields.value = fields_tmp;
|
||||
setDefaultValues();
|
||||
// Wait one tick before rendering the calendars
|
||||
setTimeout(() => {
|
||||
renderCalendars();
|
||||
}, 0);
|
||||
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
@@ -669,16 +696,17 @@ const debugGetForm = () => {
|
||||
</div>
|
||||
<!-- Date -->
|
||||
<div v-else-if="getValidator(field.validation).type === 'date'">
|
||||
<BuefyDateField
|
||||
v-model="fieldValues[field.id]"
|
||||
value-type="string"
|
||||
<input
|
||||
class="input is-link"
|
||||
:type="getValidator(field.validation).type"
|
||||
:required="isFieldRequired(field)"
|
||||
:name="field.id"
|
||||
:id="field.id"
|
||||
@change="onFieldChange(field, $event.target.value)"
|
||||
v-model="fieldValues[field.id]"
|
||||
:disabled="isFieldLocked(field)"
|
||||
:placeholder="field.metadata.placeholder"
|
||||
@change="(value) => onFieldChange(field, value)"
|
||||
/>
|
||||
v-bind:placeholder="field.metadata.placeholder"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<!-- If the validator is not defined -->
|
||||
|
||||
@@ -19,10 +19,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
userScopedUserId: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -46,7 +42,7 @@ const paginationKey = computed(() =>
|
||||
const onInviteClick = async () => {
|
||||
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
||||
paginationVersion.value += 1;
|
||||
}, { superuser: props.superuserPage, userId: props.userScopedUserId });
|
||||
}, { superuser: props.superuserPage });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -79,7 +75,6 @@ const onInviteClick = async () => {
|
||||
:key="paginationKey"
|
||||
:endpoint="endpoint"
|
||||
:show-customer="showCustomer"
|
||||
:user-scoped-user-id="userScopedUserId"
|
||||
auto-load="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="superuser-users-index" data-testid="superuser-users-index">
|
||||
<div>
|
||||
<PageTitle :title="t('superuser.pages.employees.title')" :subtitle="t('superuser.pages.employees.subtitle')">
|
||||
<template #buttons>
|
||||
<button class="button is-dark" @click="showCreateUserForm">
|
||||
@@ -24,7 +24,5 @@ const { t } = useI18n();
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.superuser-users-index {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
@@ -6,8 +6,6 @@ import Swal from "sweetalert2";
|
||||
import CustomerProductDiscountDisplay
|
||||
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getCustomerProductRestriction } from "@/features/customer/customerProductRules.js";
|
||||
import { BTooltip } from "buefy";
|
||||
|
||||
const { t } = useI18n();
|
||||
const expandIcon = ref(null);
|
||||
@@ -32,10 +30,6 @@ const props = defineProps({
|
||||
setProductAddonNote: Function,
|
||||
customerDiscounts: Array,
|
||||
customerAttributes: Array,
|
||||
customerAttributesStatus: {
|
||||
type: String,
|
||||
default: "ready",
|
||||
},
|
||||
compact: Boolean,
|
||||
showPrices: {
|
||||
type: Boolean,
|
||||
@@ -49,21 +43,6 @@ const customerAttributesSafe = computed(() => (Array.isArray(props.customerAttri
|
||||
|
||||
const getAddonProduct = (addon) => addon?.product || {};
|
||||
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) => {
|
||||
return selectedAddonsSafe.value.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
|
||||
@@ -89,9 +68,6 @@ const hideAddonPopper = (addon) => {
|
||||
};
|
||||
|
||||
const emitAddToCart = (id) => {
|
||||
if (isProductRestricted()) {
|
||||
return;
|
||||
}
|
||||
emit('addToCartProduct', props.product);
|
||||
emit('add-to-cart', id);
|
||||
};
|
||||
@@ -103,6 +79,27 @@ const toggleIsSelected = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasRestrictAdditionalServices = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
|
||||
};
|
||||
|
||||
const hasRestrictSpotFree = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
|
||||
};
|
||||
|
||||
const hasRestrictInteriorCleaning = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
|
||||
};
|
||||
|
||||
const hasRestrictTankCleaning = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
|
||||
};
|
||||
|
||||
const hasOnlyTankCleaning = () => {
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* On before add product addon
|
||||
* This is used to check if adding a product has any requirements.
|
||||
@@ -112,9 +109,6 @@ const toggleIsSelected = () => {
|
||||
* @param onAfterPreCheck
|
||||
*/
|
||||
const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
|
||||
if (isAddonRestricted(addon)) {
|
||||
return;
|
||||
}
|
||||
// Check if the addon already is selected
|
||||
if (isAddonSelected(props.id, addon.option_id)) { onAfterPreCheck(); return; }
|
||||
let note = null
|
||||
@@ -145,9 +139,6 @@ const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
|
||||
* @param onAfterPreCheck
|
||||
*/
|
||||
const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreCheck) => {
|
||||
if (!isAddonCurrentlySelected && isAddonRestricted(addon)) {
|
||||
return;
|
||||
}
|
||||
// Debug:
|
||||
console.log(addon, isAddonCurrentlySelected);
|
||||
let note = null
|
||||
@@ -171,57 +162,39 @@ const doesAddonHaveNoteRequirement = (addon) => {
|
||||
};
|
||||
|
||||
const isAddonRestricted = (addon) => {
|
||||
const readinessRestriction = getReadinessRestriction();
|
||||
if (readinessRestriction) {
|
||||
const addonName = getAddonName(addon).toLowerCase();
|
||||
// Check if the addon has "Spot Free" in the name
|
||||
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the addon has "Indvendig vask" in the name
|
||||
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
|
||||
return true;
|
||||
}
|
||||
const addonProduct = getAddonProduct(addon);
|
||||
return getCustomerProductRestriction({
|
||||
...addonProduct,
|
||||
id: getAddonTargetId(addon),
|
||||
name: getAddonName(addon) || addonProduct.name,
|
||||
category: addonProduct.category ?? addon?.category,
|
||||
}, customerAttributesSafe.value, {
|
||||
includeNumericAddonCategory: true,
|
||||
isRelatedAddon: true,
|
||||
}).restricted;
|
||||
};
|
||||
|
||||
const getAddonRestrictionMessage = (addon) => {
|
||||
const readinessRestriction = getReadinessRestriction();
|
||||
if (readinessRestriction) {
|
||||
return t(readinessRestriction.messageKey);
|
||||
const isProductRestricted = () => {
|
||||
const productName = String(props.name || '').toLowerCase();
|
||||
// Check if the product has "Spot Free" in the name
|
||||
if (hasRestrictSpotFree() && productName.includes('spot free')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product has "Indvendig vask" in the name
|
||||
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product category is 5 (Tank Cleaning)
|
||||
if (props.product?.category === 5) {
|
||||
if (hasRestrictTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
if (!hasOnlyTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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,
|
||||
name: props.name || props.product?.name,
|
||||
}, customerAttributesSafe.value);
|
||||
});
|
||||
|
||||
const isProductRestricted = () => productRestriction.value.restricted;
|
||||
const getProductRestrictionMessage = () => productRestriction.value.messageKey
|
||||
? t(productRestriction.value.messageKey)
|
||||
: "";
|
||||
|
||||
const orderByOrderPriority = (addons) => {
|
||||
return [...addons].sort((a, b) => {
|
||||
const aPriority = Number(getAddonProduct(a).order_priority ?? 0);
|
||||
@@ -252,13 +225,6 @@ const orderByOrderPriority = (addons) => {
|
||||
<template v-else>
|
||||
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><br/></p>
|
||||
</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>
|
||||
<!-- Price -->
|
||||
@@ -284,14 +250,6 @@ const orderByOrderPriority = (addons) => {
|
||||
<div class="column is-8-desktop">
|
||||
<!-- Actual product details -->
|
||||
<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 }">
|
||||
No. {{ id }}</p>
|
||||
<p class="subtitle is-6">{{ description }}</p>
|
||||
@@ -303,7 +261,20 @@ const orderByOrderPriority = (addons) => {
|
||||
<div v-if="isSelected" class="column is-12-desktop">
|
||||
<!-- Addons -->
|
||||
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
|
||||
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id">
|
||||
<template v-if="hasRestrictAdditionalServices()">
|
||||
<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)">
|
||||
<div
|
||||
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
|
||||
@@ -312,7 +283,6 @@ const orderByOrderPriority = (addons) => {
|
||||
<!-- Addon ( + ) -->
|
||||
<button
|
||||
class="button is-small"
|
||||
:data-testid="`pos-addon-${getAddonTargetId(addon)}-increase`"
|
||||
@click="onBeforeAddProductAddon(addon, () => addProductAddon(
|
||||
props.id,
|
||||
addon.option_id
|
||||
@@ -325,7 +295,6 @@ const orderByOrderPriority = (addons) => {
|
||||
<!-- quantity (if any), Addon name, price -->
|
||||
<button
|
||||
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) }"
|
||||
@click="onBeforeToggleProductAddon(addon, isAddonSelected(props.id, addon.option_id), () => toggleProductAddon(props.id, addon.option_id))"
|
||||
@mouseenter="showAddonPopper(addon, $event.target)"
|
||||
@@ -339,7 +308,6 @@ const orderByOrderPriority = (addons) => {
|
||||
<!-- Addon ( - ) -->
|
||||
<button
|
||||
class="button is-small"
|
||||
:data-testid="`pos-addon-${getAddonTargetId(addon)}-decrease`"
|
||||
:disabled="!isAddonSelected(props.id, addon.option_id)"
|
||||
@click="subtractProductAddon(
|
||||
props.id,
|
||||
@@ -353,79 +321,18 @@ const orderByOrderPriority = (addons) => {
|
||||
</div>
|
||||
</template>
|
||||
<!-- Addon is restricted -->
|
||||
<template v-else>
|
||||
<div
|
||||
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
|
||||
:data-testid="`pos-addon-restriction-${getAddonTargetId(addon)}`"
|
||||
>
|
||||
<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)}-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 v-else>
|
||||
<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>{{ addon.name }} er ikke tilladt</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
@@ -433,37 +340,15 @@ const orderByOrderPriority = (addons) => {
|
||||
<p> </p>
|
||||
<!-- Add to cart -->
|
||||
<div class="buttons is-centered mt-2 mb-3 pl-6">
|
||||
<BTooltip
|
||||
v-if="isProductRestricted()"
|
||||
:label="getProductRestrictionMessage()"
|
||||
: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-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>
|
||||
</BTooltip>
|
||||
<button
|
||||
v-else
|
||||
:data-testid="`pos-add-to-cart-${id}`"
|
||||
class="button is-small is-fullwidth is-link"
|
||||
@click="emitAddToCart(id);"
|
||||
v-bind:disabled="isProductRestricted()"
|
||||
:data-testid="`pos-add-to-cart-${id}`"
|
||||
class="button is-link is-small is-fullwidth"
|
||||
@click="emitAddToCart(id);"
|
||||
>
|
||||
<span class="icon is-small"><i class="fas fa-cart-plus"></i></span>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-cart-plus"></i>
|
||||
</span>
|
||||
<span>{{ t('pos.add_to_cart') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -498,18 +383,4 @@ const orderByOrderPriority = (addons) => {
|
||||
text-overflow: ellipsis;
|
||||
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>
|
||||
|
||||
@@ -5,10 +5,6 @@ const props = defineProps({
|
||||
clickAction: Function,
|
||||
icon: String,
|
||||
label: String,
|
||||
detailValue: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
disabled: Boolean,
|
||||
testId: {
|
||||
type: String,
|
||||
@@ -111,10 +107,6 @@ const getLabel = () => {
|
||||
return props.label ?? 'Unavngivet handling';
|
||||
}
|
||||
|
||||
const hasDetailValue = () => {
|
||||
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
|
||||
}
|
||||
|
||||
const getStyle = () => {
|
||||
return styles[props.template] ?? styles.default;
|
||||
}
|
||||
@@ -148,40 +140,18 @@ const getLabelColor = () => {
|
||||
<span class="icon">
|
||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||
</span>
|
||||
<span class="dropdown-item-action__label ml-1"
|
||||
<span class="ml-1"
|
||||
:class="getLabelColor()"
|
||||
>{{ getLabel() }}</span>
|
||||
<span v-if="hasDetailValue()" class="dropdown-item-action__detail">
|
||||
{{ props.detailValue }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-item-action {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
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 {
|
||||
|
||||
@@ -8,14 +8,6 @@ const props = defineProps({
|
||||
default: undefined,
|
||||
},
|
||||
label: String,
|
||||
detailValue: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
appearance: {
|
||||
type: String,
|
||||
default: "default",
|
||||
},
|
||||
template: {
|
||||
type: String,
|
||||
default: 'default', // The style of the button (default, danger, success, warning, info, light)
|
||||
@@ -94,10 +86,6 @@ const getLabel = () => {
|
||||
return props.label ?? 'Unavngivet handling';
|
||||
}
|
||||
|
||||
const hasDetailValue = () => {
|
||||
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
|
||||
}
|
||||
|
||||
const getStyle = () => {
|
||||
return styles[props.template] ?? styles.default;
|
||||
}
|
||||
@@ -121,55 +109,25 @@ const getLabelColor = () => {
|
||||
const hasIcon = () => {
|
||||
return props.icon !== undefined && props.icon !== null;
|
||||
}
|
||||
|
||||
const isMetadataAppearance = () => props.appearance === "metadata";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
class="dropdown-item dropdown-item-label is-disabled"
|
||||
:class="{ 'dropdown-item-label--metadata': isMetadataAppearance() }"
|
||||
>
|
||||
<span class="icon" v-if="hasIcon()">
|
||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||
</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 class="dropdown-item is-disabled">
|
||||
<span class="icon" v-if="hasIcon()">
|
||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||
</span>
|
||||
<span class="ml-1"
|
||||
:class="getLabelColor()"
|
||||
>{{ SessionUser.functions.ucFirst(getLabel()) }}</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.is-disabled {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
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 {
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -177,22 +135,4 @@ const isMetadataAppearance = () => props.appearance === "metadata";
|
||||
.is-disabled .icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.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>
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<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,11 +62,7 @@ const onClick = async () => {
|
||||
:title="title"
|
||||
@click.stop.prevent="onClick"
|
||||
>
|
||||
<span class="action-settings-wheel-toggle-item__label">
|
||||
<slot name="label" :label="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</span>
|
||||
<span class="action-settings-wheel-toggle-item__label">{{ label }}</span>
|
||||
<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>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
customer_id,
|
||||
customer_data,
|
||||
customer_attributes,
|
||||
department_id,
|
||||
loadCustomerAttributes,
|
||||
hideDiscountsCatalog,
|
||||
hidePricesCatalog,
|
||||
@@ -21,11 +20,17 @@ import "bulma-switch/dist/css/bulma-switch.min.css";
|
||||
import "bulma-block-list/src/block-list.scss";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
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 CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
||||
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const emit = defineEmits(["update:activeTab"]);
|
||||
@@ -167,9 +172,6 @@ const refreshCustomerAttributesForRules = async (selectedCustomerNumber = custom
|
||||
const hasAttribute = (prop) => {
|
||||
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
||||
};
|
||||
const attributeEntry = (prop) => (
|
||||
customer_attributes.value.find((attribute) => attribute.attribute === prop) ?? null
|
||||
);
|
||||
|
||||
watch(
|
||||
activeTabKey,
|
||||
@@ -274,16 +276,12 @@ const customer_data_has_empty_details = () => {
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<CustomerRuleTooltip
|
||||
:attribute="attribute.attribute"
|
||||
:active="hasAttribute(attribute.prop)"
|
||||
:customer-number="customer_id"
|
||||
:department-id="department_id"
|
||||
:restriction="attributeEntry(attribute.prop)"
|
||||
:test-id="`pos-customer-rule-tooltip-${attribute.attribute}`"
|
||||
<span
|
||||
class="pos-selected-customer__label"
|
||||
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span
|
||||
>
|
||||
<span class="pos-selected-customer__label">{{ attribute.name }}</span>
|
||||
</CustomerRuleTooltip>
|
||||
<span class="pos-selected-customer__value">
|
||||
<span
|
||||
v-if="hasAttribute(attribute.prop)"
|
||||
|
||||
@@ -24,24 +24,8 @@ const onCustomerChange = () => {
|
||||
isCustomerSelected.value = false;
|
||||
}
|
||||
|
||||
const parseFixedPriceValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
|
||||
const parseValue = (discount) => {
|
||||
const fixedPrice = parseFixedPriceValue(discount.fixed_price);
|
||||
if (fixedPrice !== null) {
|
||||
return `${formatNumber(fixedPrice)} Kr.`;
|
||||
}
|
||||
|
||||
let tmp_value = parseFloat(discount.percentage).toFixed(2);
|
||||
const parseValue = (value) => {
|
||||
let tmp_value = parseFloat(value).toFixed(2);
|
||||
// Add the percentage sign
|
||||
return `${tmp_value}%`;
|
||||
}
|
||||
@@ -94,7 +78,7 @@ watch(customer_id, onCustomerChange, { immediate: true });
|
||||
<!--<span class="icon is-small mr-1">
|
||||
<i class="fas fa-percent" aria-hidden="true"></i>
|
||||
</span> -->
|
||||
<span>{{ parseValue(discount) }}</span>
|
||||
<span>{{ parseValue(discount.percentage) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,4 +86,4 @@ watch(customer_id, onCustomerChange, { immediate: true });
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -29,8 +29,6 @@ const discount_product = ref(null);
|
||||
const discount_category = ref(null);
|
||||
// The global discount (if any)
|
||||
const discount_global = ref(null);
|
||||
// The fixed product price (if any)
|
||||
const fixed_product_price = ref(null);
|
||||
|
||||
// Show the discounts dropdown
|
||||
const showDiscountsDropdown = ref(false);
|
||||
@@ -51,19 +49,6 @@ const hasGlobalDiscount = () => {
|
||||
return discount_global.value > 0;
|
||||
}
|
||||
|
||||
const hasFixedProductPrice = () => {
|
||||
return fixed_product_price.value !== null;
|
||||
}
|
||||
|
||||
const parseFixedPriceValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
// Set the product discount
|
||||
const setProductDiscount = (percentage) => {
|
||||
// Check if the percentage is a number
|
||||
@@ -98,16 +83,6 @@ const setGlobalDiscount = (percentage) => {
|
||||
discount_global.value = percentage;
|
||||
}
|
||||
|
||||
const setFixedProductPrice = (fixedPrice) => {
|
||||
const parsedFixedPrice = parseFixedPriceValue(fixedPrice);
|
||||
if (parsedFixedPrice === null) {
|
||||
fixed_product_price.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
fixed_product_price.value = parsedFixedPrice;
|
||||
}
|
||||
|
||||
|
||||
// Debugging function
|
||||
const getDiscountDebug = () => {
|
||||
@@ -126,15 +101,9 @@ const getDiscountDebug = () => {
|
||||
|
||||
// Parse the customer discounts
|
||||
const parseCustomerDiscounts = () => {
|
||||
discount_product.value = null;
|
||||
discount_category.value = null;
|
||||
discount_global.value = null;
|
||||
fixed_product_price.value = null;
|
||||
|
||||
const customerDiscounts = Array.isArray(props.customer_discounts) ? props.customer_discounts : [];
|
||||
const tmp_discount_product = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.id && Number(discount.is_category) === 0)
|
||||
const tmp_discount_category = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.category && Number(discount.is_category) === 1);
|
||||
const tmp_discount_global = customerDiscounts.find((discount) => discount.id === 999999 && Number(discount.is_category) === 1);
|
||||
const tmp_discount_product = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.id && !discount.is_category)
|
||||
const tmp_discount_category = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.category && discount.is_category);
|
||||
const tmp_discount_global = props.customer_discounts.find((discount) => discount.id === 999999 && discount.is_category);
|
||||
|
||||
//console.log("Product discount: ", tmp_discount_product);
|
||||
//console.log("Category discount: ", tmp_discount_category);
|
||||
@@ -143,7 +112,6 @@ const parseCustomerDiscounts = () => {
|
||||
// Set the product discount
|
||||
if (tmp_discount_product) {
|
||||
setProductDiscount(tmp_discount_product.percentage);
|
||||
setFixedProductPrice(tmp_discount_product.fixed_price);
|
||||
}
|
||||
// Set the category discount
|
||||
if (tmp_discount_category) {
|
||||
@@ -157,10 +125,6 @@ const parseCustomerDiscounts = () => {
|
||||
|
||||
// Get the best discount for the customer
|
||||
const getBestDiscount = () => {
|
||||
if (hasFixedProductPrice()) {
|
||||
highestEligibleDiscount.value = 0;
|
||||
return 0;
|
||||
}
|
||||
// Set the best discount to 0
|
||||
let tmp_best_discount = 0;
|
||||
// Check if the product discount is higher than the current best discount
|
||||
@@ -218,32 +182,13 @@ watch(() => props.customer_discounts, () => {
|
||||
<span
|
||||
aria-haspopup="true"
|
||||
aria-controls="dropdown-menu"
|
||||
v-if="hasFixedProductPrice()"
|
||||
class="tag is-success is-light is-text text-can-not-select"
|
||||
>{{ fixed_product_price }} Kr.</span>
|
||||
<span
|
||||
aria-haspopup="true"
|
||||
aria-controls="dropdown-menu"
|
||||
v-else-if="highestEligibleDiscount > 0"
|
||||
v-if="highestEligibleDiscount > 0"
|
||||
class="tag is-warning is-light is-text text-can-not-select"
|
||||
> -{{ highestEligibleDiscount }}%</span>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content py-0">
|
||||
<div class="list has-overflow-ellipsis" style="width: 340px">
|
||||
<template v-if="hasFixedProductPrice()">
|
||||
<a class="list-item">
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-title">Fast pris</div>
|
||||
</div>
|
||||
<div class="list-item-controls list-item-controls-force-visible">
|
||||
<div class="tags has-addons">
|
||||
<span class="tag is-success is-light">{{ fixed_product_price }} Kr.</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Best discount -->
|
||||
<a class="list-item">
|
||||
<div class="list-item-content">
|
||||
@@ -294,7 +239,6 @@ watch(() => props.customer_discounts, () => {
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,4 +254,4 @@ watch(() => props.customer_discounts, () => {
|
||||
.text-can-not-select {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,122 +0,0 @@
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
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",
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
|
||||
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
||||
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
||||
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
||||
import {
|
||||
@@ -378,8 +377,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
|
||||
try {
|
||||
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
|
||||
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
|
||||
todayLocalDateOnly()
|
||||
},created_at-date_to:${todayLocalDateOnly()}`,
|
||||
new Date().toISOString().split("T")[0]
|
||||
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,17 +38,14 @@ import {
|
||||
reg_2,
|
||||
reg_3,
|
||||
department_id,
|
||||
customer_id,
|
||||
customer_attributes,
|
||||
customer_attributes_status,
|
||||
getCustomerEmail,
|
||||
customer_name,
|
||||
getAddonRestriction,
|
||||
getProductRestriction,
|
||||
retryCustomerAttributes,
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
customer_id,
|
||||
getCustomerEmail,
|
||||
customer_name,
|
||||
isAddonRestricted,
|
||||
canBuyAdditionalServices,
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.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";
|
||||
@@ -346,14 +343,13 @@ const applyPendingBookingFromSelection = async () => {
|
||||
effectivePrimaryProduct = firstWash;
|
||||
}
|
||||
}
|
||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||
if (transactionItems.primaryItem.value) {
|
||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||
}
|
||||
sanitizeRestrictedTransactionItems();
|
||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||
if (transactionItems.primaryItem.value) {
|
||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||
}
|
||||
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
||||
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
||||
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
||||
@@ -485,127 +481,9 @@ const layout = {
|
||||
};
|
||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
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) => {
|
||||
if (customer_attributes_status.value !== "ready") {
|
||||
restrictionWarningMessageKey.value = getRestrictionReadinessMessageKey();
|
||||
return;
|
||||
}
|
||||
lastOrders.select(vehicleIndex);
|
||||
sanitizeRestrictedTransactionItems();
|
||||
};
|
||||
|
||||
const openCustomerSelection = () => {
|
||||
@@ -908,11 +786,10 @@ const buildDesiredOrderItemShapes = () => {
|
||||
related_item_id: null,
|
||||
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
||||
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
|
||||
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
|
||||
};
|
||||
|
||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return {
|
||||
@@ -923,12 +800,11 @@ const buildDesiredOrderItemShapes = () => {
|
||||
related_item_id: "__PRIMARY__",
|
||||
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
|
||||
};
|
||||
});
|
||||
|
||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.map((item: any) => ({
|
||||
kind: "additional",
|
||||
relatedKey: null,
|
||||
@@ -937,7 +813,6 @@ const buildDesiredOrderItemShapes = () => {
|
||||
related_item_id: null,
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ""),
|
||||
skip_price_override: item?.skip_price_override === true,
|
||||
}));
|
||||
|
||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||
@@ -989,8 +864,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
||||
};
|
||||
|
||||
const addonShapes = sortComparableLastWashShapes(
|
||||
(transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
(transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => ({
|
||||
kind: "addon",
|
||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||
@@ -999,8 +874,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
||||
);
|
||||
|
||||
const additionalShapes = sortComparableLastWashShapes(
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.map((item: any) => ({
|
||||
kind: "additional",
|
||||
product_id: Number(item?.id ?? 0),
|
||||
@@ -1069,21 +944,13 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
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 existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
||||
|
||||
const desiredShapes = buildDesiredOrderItemShapes();
|
||||
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 (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
|
||||
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1095,12 +962,12 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
1,
|
||||
null,
|
||||
transactionItems.primaryItem.value?.notes || "",
|
||||
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
|
||||
transactionItems.primaryItem.value.price
|
||||
);
|
||||
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
|
||||
|
||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return createOrderItem(
|
||||
@@ -1109,23 +976,14 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
Number(addon.quantity),
|
||||
createdPrimaryItemId,
|
||||
addonProduct?.notes || addon?.notes || "",
|
||||
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
|
||||
? null
|
||||
: addonProduct.price ?? addon.price
|
||||
addonProduct.price ?? addon.price
|
||||
);
|
||||
});
|
||||
|
||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.map((item: any) =>
|
||||
createOrderItem(
|
||||
normalizedOrderId,
|
||||
item.id,
|
||||
Number(item.quantity),
|
||||
null,
|
||||
item?.notes || "",
|
||||
item?.skip_price_override === true ? null : item.price
|
||||
)
|
||||
createOrderItem(normalizedOrderId, item.id, Number(item.quantity), null, item?.notes || "", item.price)
|
||||
);
|
||||
|
||||
await Promise.all([...addonPromises, ...additionalPromises]);
|
||||
@@ -1164,8 +1022,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
||||
missingProducts.push(primaryProduct);
|
||||
}
|
||||
|
||||
(primaryProduct?.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
(primaryProduct?.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.forEach((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
if (
|
||||
@@ -1177,8 +1035,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
||||
}
|
||||
});
|
||||
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.forEach((item: any) => {
|
||||
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||
missingProducts.push(item);
|
||||
@@ -1268,14 +1126,6 @@ const onBeforeComplete = async () => {
|
||||
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())) {
|
||||
return false;
|
||||
}
|
||||
@@ -1306,20 +1156,13 @@ const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
|
||||
const sourceAddonProductId = getAddonProductId(addon);
|
||||
const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId);
|
||||
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 {
|
||||
...addon,
|
||||
quantity: nextQuantity,
|
||||
skip_price_override: skipPriceOverride,
|
||||
product: addon?.product
|
||||
? {
|
||||
...addon.product,
|
||||
quantity: nextQuantity,
|
||||
skip_price_override: skipPriceOverride,
|
||||
}
|
||||
: addon?.product,
|
||||
};
|
||||
@@ -1429,20 +1272,6 @@ 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(
|
||||
() => vehicles.vehicle_1.value?.reference,
|
||||
@@ -1453,30 +1282,20 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
// Computed property to annotate restricted addons based on customer attributes
|
||||
// Computed property to filter out restricted addons based on customer attributes
|
||||
const filteredAddons = computed(() => {
|
||||
const addons = transactionItems.primaryItem.value?.addons || [];
|
||||
return addons.map((addon: any) => decorateMobileAddonRestriction(addon));
|
||||
// If additional services are restricted, return empty array
|
||||
if (!canBuyAdditionalServices()) {
|
||||
return [];
|
||||
}
|
||||
// Filter out individually restricted addons
|
||||
return addons.filter((addon: any) => !isAddonRestricted(addon));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
@@ -1534,32 +1353,10 @@ const filteredAddons = computed(() => {
|
||||
<i class="fa fa-search"></i>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Registration numbers -->
|
||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||
<p
|
||||
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
|
||||
<!-- Registration numbers -->
|
||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||
<!-- Product -->
|
||||
<PosDepartmentStepMobile2Product
|
||||
v-on:pointerdown="onPrimaryProductPointerDown"
|
||||
v-on:pointermove="onPrimaryProductPointerMove"
|
||||
v-on:pointerup="onPrimaryProductPointerUp"
|
||||
|
||||
@@ -1,63 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { PosOrder } from "../objects/PosOrder.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
|
||||
import PosDepartmentStepMobile2Addons from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
|
||||
import { Addon } from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import { transactionItems } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
|
||||
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
import PosDepartmentStepMobile2CategoryProduct
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
|
||||
import PosDepartmentStepMobile2Addons
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
|
||||
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import {
|
||||
customer_id,
|
||||
customer_attributes_status,
|
||||
getProductRestriction,
|
||||
retryCustomerAttributes,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { orderProducts } from "@/components/shop/Products.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const ADDITIONAL_SERVICES_CATEGORY_ID = 8;
|
||||
let latestAdditionalSelectionProductsRequestId = 0;
|
||||
transactionItems
|
||||
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStep2MobileVehicleSelection
|
||||
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import PosDepartmentStepMobile2FloatingCart
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
|
||||
import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "Additional items",
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "Click to modify your additional items",
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
defaultChecked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: false
|
||||
},
|
||||
lastOrder: {
|
||||
type: Object as () => PosOrder | null,
|
||||
default: null,
|
||||
required: false,
|
||||
required: false
|
||||
},
|
||||
});
|
||||
|
||||
const restrictionWarningMessageKey = ref("");
|
||||
const additionalSelectionProducts = ref<PosProduct[]>([]);
|
||||
const additionalSelectionLoading = ref(false);
|
||||
const canSelectAdditionalItems = computed(() => true);
|
||||
const checked = ref(props.defaultChecked);
|
||||
// Function to generate a summary from the last order
|
||||
function generateSummary(order: PosOrder): string {
|
||||
if (!order || !order.items || order.items.length === 0) {
|
||||
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));
|
||||
return uniqueItems.length > 1
|
||||
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
|
||||
@@ -71,7 +66,9 @@ const displayLabel = computed(() => {
|
||||
: props.label;
|
||||
});
|
||||
const displaySubtitle = computed(() => {
|
||||
return props.lastOrder ? generateSummary(props.lastOrder) : props.subtitle;
|
||||
return props.lastOrder
|
||||
? generateSummary(props.lastOrder)
|
||||
: props.subtitle;
|
||||
});
|
||||
// Emit event on toggle
|
||||
function onToggle(isOpen: boolean) {
|
||||
@@ -87,22 +84,19 @@ const exampleProducts = ref<PosProduct[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: "Extra Towel",
|
||||
price: 5.0,
|
||||
price: 5.00,
|
||||
description: "A soft extra towel",
|
||||
subscription_allowed: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Premium Soap",
|
||||
price: 3.5,
|
||||
price: 3.50,
|
||||
description: "A premium quality soap",
|
||||
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);
|
||||
return {
|
||||
id: product.id,
|
||||
@@ -112,264 +106,84 @@ const convertProductToAddon = (
|
||||
quantity: options.quantity || 0,
|
||||
min: options.min || -1,
|
||||
max: options.max || -1,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
const getAvailableAdditionalItems = () => {
|
||||
const tmp = transactionItems.additionalItems.value || [];
|
||||
if (!tmp || tmp.length === 0) return [];
|
||||
return <Addon[]>tmp.map((p) =>
|
||||
convertProductToAddon(p, {
|
||||
quantity: p?.quantity || 0,
|
||||
min: -1,
|
||||
max: -1,
|
||||
})
|
||||
);
|
||||
};
|
||||
return <Addon[]>tmp.map(p => convertProductToAddon(p, {
|
||||
quantity: p?.quantity || 0,
|
||||
min: -1,
|
||||
max: -1,
|
||||
}));
|
||||
}
|
||||
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
|
||||
const isAdditionalItemRestricted = (product: PosProduct) => {
|
||||
return getAdditionalItemRestriction({ product } as Addon).restricted;
|
||||
};
|
||||
|
||||
const getAdditionalSelectionRestrictionMessage = (product: PosProduct) => {
|
||||
const restriction = getAdditionalItemRestriction({ product } as Addon);
|
||||
return restriction.messageKey ? t(restriction.messageKey) : "";
|
||||
};
|
||||
|
||||
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
|
||||
const filteredAdditionalItems = computed(() => {
|
||||
return availableAdditionalItems.value.map((addon: Addon) => decorateAdditionalItemAddon(addon));
|
||||
// If additional services are restricted, return empty array
|
||||
if (!canBuyAdditionalServices()) {
|
||||
return [];
|
||||
}
|
||||
// Filter out individually restricted addons
|
||||
return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon));
|
||||
});
|
||||
const onClickAddOtherProduct = () => {
|
||||
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
|
||||
if (pos.views.additionalItemSelection.value) {
|
||||
scheduleAdditionalSelectionProductsLoad();
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdditionalSelectionProducts = async () => {
|
||||
if (additionalSelectionLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
availableAdditionalItems,
|
||||
(newVal) => {
|
||||
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();
|
||||
watch(availableAdditionalItems, (newVal) => {
|
||||
newVal.forEach(addon => {
|
||||
if (addon.quantity && addon.quantity > 0 && addon.product) {
|
||||
addon.product.quantity = addon.quantity; // Ensure product has correct quantity
|
||||
}
|
||||
|
||||
const currentItems = pos.transactionItems.additionalItems.value || [];
|
||||
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 }
|
||||
);
|
||||
pos.transactionItems.setAdditionalItems(newVal.filter(a => a.quantity && a.quantity > 0).map(a => a.product!).filter((p): p is PosProduct => !!p) );
|
||||
});
|
||||
}, { deep: true });
|
||||
|
||||
// Watch for changes in pos.transactionItems.additionalItems to remove items with quantity 0
|
||||
watch(
|
||||
() => pos.transactionItems.additionalItems.value,
|
||||
(newVal) => {
|
||||
if (!newVal) return;
|
||||
if (newVal.length === 0) {
|
||||
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();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (pos.views.additionalItemSelection.value) {
|
||||
scheduleAdditionalSelectionProductsLoad();
|
||||
}
|
||||
});
|
||||
|
||||
const onClickAddProduct = async (product: PosProduct) => {
|
||||
if (isAdditionalItemRestricted(product)) {
|
||||
showRestrictedItemsRemovedWarning();
|
||||
watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
|
||||
if (!newVal) return;
|
||||
if (newVal.length === 0) {
|
||||
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 });
|
||||
|
||||
const onClickAddProduct = async (product: PosProduct) => {
|
||||
// If the product requires note, open note input.
|
||||
pos.transactionItems.addAdditionalItem(product);
|
||||
// If the view is fullscreen, close it after adding
|
||||
//if (pos.views.additionalItemSelection.value) {
|
||||
// pos.views.additionalItemSelection.value = false;
|
||||
//}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="pos-mobile-additional-items">
|
||||
<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>
|
||||
<!-- 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"
|
||||
>
|
||||
<!-- Minimal view, when not set as fullscreen view -->
|
||||
<WhiteBoxCard :toggleable="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 -->
|
||||
<template #header>
|
||||
<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
|
||||
class="card-header-icon pos-mobile-additional-items-header__icon"
|
||||
data-testid="pos-mobile-additional-items-header-icon"
|
||||
>
|
||||
<div 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 -->
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
@@ -379,13 +193,6 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
<template #content>
|
||||
<!-- Suggested items -->
|
||||
<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 -->
|
||||
<div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0">
|
||||
<p>{{ SessionUser.objects.global.language.no_additional_items }}</p>
|
||||
@@ -407,52 +214,22 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
<!-- Fullscreen view, when selecting other products -->
|
||||
<template v-else>
|
||||
<div data-testid="pos-mobile-additional-items-selection">
|
||||
<div class="pos-mobile-additional-items-categories">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-rounded is-small is-dark"
|
||||
:data-testid="`pos-mobile-category-${ADDITIONAL_SERVICES_CATEGORY_ID}`"
|
||||
@click="scheduleAdditionalSelectionProductsLoad"
|
||||
>
|
||||
Extras
|
||||
</button>
|
||||
</div>
|
||||
<div class="pos-mobile-additional-items-products">
|
||||
<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>
|
||||
<!-- Categories of products -->
|
||||
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
|
||||
<!-- 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>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -477,10 +254,4 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pos-mobile-additional-items-categories {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||