Compare commits

..
Author SHA1 Message Date
df7563c15d fix(invoicing-flag-list): explain empty XL Vask hover preview when flag context has no metadata (AUT-4/TRU-8) (#311)
When a flagged wash has no metadata in the api flag-context payload, the
user previously saw 'No XL Vask details available.' with no indication
of why. This adds a clearer fallback message and adds unit coverage for
that scenario.

Resolves TRU-8 (AUT-4).

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:55:13 +02:00
Jeppe BandJeppe 0b1df55728 test(TRU-19): lock self-serve program number range + button registry contract (#310)
## Summary

TRU-19 reports that the wash programs **FF Uvs** and **10min** are
unresponsive on the Pleno bay while **SF** works. The failing programs
are mapped by the API/backend — the frontend only forwards normalized
program numbers and never resolves program identifiers to bay commands,
so the bug itself cannot be fixed in this repo.

This PR adds two frontend-side smoke checks that lock the contract
with the wash bay so the next reader (or the next agent) cannot go
down the same dead end and so any future contract drift surfaces as a
deliberate test failure rather than a silent UI regression:

1. **Helper range** — `isSelfServeProgramNumberButton` accepts exactly
   the 12 program numbers `0..11` and rejects the boundary values
   (`-1`, `12`, `100`); `parseSelfServeDynamicImageThumbPosition`
   maps the 1-indexed thumb positions `1..12` to the same range and
   rejects `0`, `13`, and non-numeric / nullish input.
2. **Button registry** — `SELF_SERVE_TASK_BUTTON_OPTIONS` exposes 12
   unique, sequential numeric program entries (ids `0..11`, all unique
   names) and still contains the three special buttons (`reset`,
   `program_picker`, `start`) so the path editor and simulator UI can
   render every program the bay supports.

## Why this is the right frontend-side fix

- The frontend never sees the program identifiers (`FF Uvs`, `10min`,
  `SF`) — those live in the API repo's machine mapping table. Trying
  to "fix" the unresponsiveness here would only mask the bug.
- The upstream helper functions and the button registry are the
  pieces of pleno-vue that participate in the program contract. Any
  shrinking, widening, or duplication of the supported program set
  will now fail CI before reaching review.
- The doc comments on the new tests point the next reader at the API
  repo as where the actual mapping fix belongs, so we do not repeat
  the false-lead investigation.

## Commits

- `743d4e4` test(self-serve): smoke check program number range (0-11)
  and thumb position (1-12)
- `0f3b125` test(self-serve): smoke check button registry has 12
  unique sequential programs

## Tests

- `npx vitest run tests/unit/self-serve-dynamic-image.spec.js`
  → **9 passed (9)**
- `npx vitest run` (full suite) → **1765 passed (1765)**, 254 files
- `npx eslint tests/unit/self-serve-dynamic-image.spec.js` → clean
- `git diff --check HEAD~2..HEAD` → no whitespace/conflict-marker
  issues

## Issue

- Linear: TRU-19 (`AUT-15: pleno-vue + api — wash programs "FF Uvs"
  and "10min" are unresponsive; "SF" works`)
- This PR closes the frontend-side portion of the bug. The actual
  program mapping fix must be applied in the `api` repo.

> _This PR description was generated by an AI agent (OpenHands) on
> behalf of the user._

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
2026-08-15 20:53:29 +02:00
Jeppe Bandopenhands 398a0a146f feat(TRU-11): propagate department selector to Selvvask usage query (#309)
## Summary

- `XLVaskUsagePagination` now accepts a `departmentId` prop and, when
set (or when the route's `departmentId` param is set), constrains the
`/modules/xlvask/services/usage/orders` request to that department via a
`HallId` filter.
- Previously the request went out unfiltered, so the Selvvask tab could
return washes for unrelated departments (or none, depending on the
endpoint contract).
- `DepartmentPosSync` forwards the URL department id to the pagination
via the new prop. The superuser `InvoicingBillingPeriodViewSelfWash`
view is unaffected because it does not pass `departmentId`.

## Test plan

- [x] `tests/unit/xlvask-usage-department-filter.spec.js` locks the prop
declaration, the HallId filter wiring, the route-param fallback, the
negative path, and the consumer contract.
- [x] `npm run lint` is clean.
- [x] `npm run format:tests:check` is clean.
- [x] `npm run text:check-encoding` is clean.
- [x] Full vitest suite: 255 files / 1768 tests pass.

Refs: TRU-11 (AUT-7)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:53:15 +02:00
dfaca1e8a5 i18n(xlvask_review): translate missing keys for no/sv/de/en + locale coverage test (#307)
## Summary

The `invoicing_period.xlvask_review.*` and
`invoice_period.flags.preview.xlvask_usage_log` keys were already
comprehensively localised in `no`, `sv`, `de`, and `en` — the five
active locales (`da`, `en`, `sv`, `de`, `no`) each carry the same 173
`xlvask_review` keys, and the remaining English literal in
`flags.automatic.xlvask_missing_order_link` for `no`/`sv`/`de` has been
replaced with native Norwegian / Swedish / German translations that
mirror the `da` reference catalogue. This PR locks that coverage in with
a focused unit test so future da-side edits cannot silently drop a
translation for any of the non-da locales.

## Commits

- `49ff30f` — TRU-13 / AUT-9: translate xlvask_missing_order_link to
no/sv/de
- `550d32c` — TRU-13 / AUT-9: add xlvask_review translation coverage
regression test

## Verification

- `npm run i18n:v2:check` — clean across source-check,
global-template-audit, template-dedupe-audit, and word-audit.
- `npx vitest run tests/unit/i18n-xlvask-review-coverage.spec.js` — 1 /
1 pass (5 locales × 173 keys verified).
- `npx vitest run tests/unit/i18n-*` — 11 / 11 pass.
- `npx playwright test tests/e2e/i18n-v2-integrity.spec.ts` — 126 / 126
pass across all 9 browser projects.

The new test in `tests/unit/i18n-xlvask-review-coverage.spec.js`
enumerates every leaf string under
`compat.invoicing_period.xlvask_review` in `da`, then asserts for each
of `no`, `sv`, `de`, `en` that every key is present and that every
localised value is a non-empty string. Verified it catches regressions
by temporarily removing a key from `no` and observing the test fail with
the missing key listed.

Closes TRU-13.

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:53:01 +02:00
a4a3b4adb1 i18n(test): lock in xlvask_review / xlvask_usage_log mirroring to the global v2 fallback (#306)
## Summary

Mirror `xlvask_review` keys (under `invoicing_period.xlvask_review.*`)
and `invoice_period.flags.preview.xlvask_usage_log` keys from the `da`
source into the `global` i18n fallback so other locales (no, sv, de, en)
resolve them instead of falling through to raw key paths.

The implementation was already correct in the source tree:
- `src/i18n/source/global/shared/invoicing_period/xlvask_review.json`
mirrors all 173 da keys via `@:{}:` linked references.
- `src/i18n/source/global/shared/invoice_period/flags.json` mirrors
`flags.preview.xlvask_usage_log` keys (and adds a shared
`no_xlvask_usage_log` alias).
- All 5 locales (da, en, sv, de, no) carry the same 173 `xlvask_review`
keys, so the global fallback resolves cleanly for every active locale.

This PR adds two regression tests in
`tests/e2e/i18n-v2-integrity.spec.ts` that lock in the mirror so future
da-side edits cannot silently drop the fallback for non-da locales.

## Commits

- `0c0bd7c` — i18n(test): lock in xlvask_review mirroring to the global
v2 fallback
- `6ed2f8d` — i18n(test): lock in xlvask_usage_log flag mirroring to the
global v2 fallback

## Verification

- `npm run i18n:v2:check` — clean across source-check,
global-template-audit, template-dedupe-audit, and word-audit.
- `npx playwright test tests/e2e/i18n-v2-integrity.spec.ts` — 126 / 126
pass across all 9 browser projects, including the two new regression
tests.

Closes TRU-9.

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:52:48 +02:00
b2f77b45e5 feat(xlvask): render friendly notice for 404 from usage/orders endpoint (#305)
Fixes AUT-8 (TRU-12).

## Problem
The Selvvask (XLVask) usage pagination in `XLVaskUsagePagination.vue`
calls `/modules/xlvask/services/usage/orders`, an endpoint that ships in
the frontend ahead of the backend implementation. While the backend is
still being built, the route responds with HTTP 404 — which
`paginatedList` was treating identically to any other error and
surfacing through the generic `ShowErrorField` popper. Operators hitting
the Selvvask tab during that window saw an alarming failure message
instead of an honest "not implemented yet" notice.

## Fix
- **`src/components/pagination/paginatedList.vue`**: introduce a
reactive `lastError` ref on the composable that captures `{ status,
endpoint, message }` from the most recent failed `paginatedGetRequest`
call. The ref is reset on success, left untouched for cancelled
requests, and exposed both on the composable's return and on the global
instance so other surfaces can opt into status-aware error handling.
Non-cancelled failures continue to populate the existing global error
store via `parseError`.
-
**`src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue`**:
derive an `apiEndpointNotImplemented` computed that is `true` only when
`lastError.status === 404` *and* the failing endpoint matches this
component's own `/modules/xlvask/services/usage/orders`. A watcher on
that computed clears the matching `paginatedGetRequest` entry from the
global error store, and the template renders a friendly, info-styled
Bulma notice between `PaginationDisplay` and `ShowErrorField`. Non-404
errors (500, network failures, etc.) still fall through to the generic
popper unchanged.

## i18n
New `errors.api_endpoint_not_implemented` key under
`invoicing_period.xlvask_review`, added to all five source locales (en,
da, de, no, sv) plus the global shared template alias, then regenerated
into the i18n v2 runtime files via `npm run i18n:v2:compile`. Each
locale now reads as a real translation rather than a raw key in the UI.

## Tests
- `tests/unit/superuser-invoices-view.spec.js`: two new contract
assertions verify that `XLVaskUsagePagination.vue` references
`apiEndpointNotImplemented`, `lastError`, the `404` literal, the new
i18n key path, the `xlvask-api-not-implemented` test id, and the
`removeError("paginatedGetRequest")` call; the locale assertion confirms
the key resolves in every supported locale.
- `tests/unit/xlvask-usage-pagination-404.spec.js` (new): four runtime
cases against `usePaginatedList` cover the success path, 404 capture,
reset on the next request, and skip-on-cancel.
- Full unit suite: **1768/1768 pass across 255 files**.
- ESLint clean on all four modified files plus the new spec.
- `npm run i18n:v2:check` (source check, global-template audit, template
dedupe, word audit) all green.
- No new dependencies; uses existing `vue` (`ref`, `computed`, `watch`),
`axios` response shape, and `vue-i18n`.

## Files changed (16 files, +176 / -16)
- `src/components/pagination/paginatedList.vue` (+14 / -3)
-
`src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue`
(+23 / -3)
-
`src/i18n/source/{en,da,de,no,sv}/phrases/compat/invoicing_period/xlvask_review.json`
(+3 each / -2 each, net +1 per locale)
- `src/i18n/source/global/shared/invoicing_period/xlvask_review.json`
(+1)
- `src/i18n/generated/{en,da,de,no,sv}-v2.json`, `global-v2.json`
(regenerated)
- `tests/unit/superuser-invoices-view.spec.js` (+24)
- `tests/unit/xlvask-usage-pagination-404.spec.js` (new, +90)

Logic-only frontend change; no visual diff beyond a single info notice
(which is the entire point of the fix). No backend API endpoint touched.

_This PR was created by an AI agent (OpenHands) on behalf of the
OpenSymphony autonomous workflow._

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:52:36 +02:00
f0b3fc4675 fix(invoicing-period): propagate flagged wash start date to Selvvask view (#304)
Fixes AUT-6 (TRU-10).

## Problem
When a user clicked an `xlvask_usage_log` token from a flag in the
InvoicingPeriod flag list, `openXlVaskUsageLog(flag)` navigated to:

```
/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=<id>
```

`InvoicingBillingPeriodViewSelfWash.vue` already read
`route.query.xlvaskUsageLogId` and passed it to `XLVaskUsagePagination`
as `highlight-usage-log-id`. However, the date range was hardcoded to
`dates.computed.formattedStartDate.value` /
`dates.computed.formattedEndDate.value` — the parent period view's
range. If the flag was emitted for a wash **outside** that range (e.g. a
flagged wash from a previous invoicing period), the highlighted wash was
filtered out of the Selvvask result list.

## Fix
- Forward the wash's start time as the new
`xlvaskUsageLogStartTime=YYYY-MM-DD` query parameter when navigating
from the flag list. The `extractDateOnly` helper in
`InvoicingPeriodFlagList.vue` accepts both `flag.context.start_time` and
`flag.start_time`, and tolerates the full ISO-8601 form (e.g.
`2026-04-28T08:15:00`).
- `InvoicingBillingPeriodViewSelfWash.vue` reads
`route.query.xlvaskUsageLogStartTime` via a `parseRouteDateOnly` helper
and uses the wash's date as both `initialDateFrom` and `initialDateTo`.
When the parameter is absent (e.g. direct navigation to Selvvask), the
view falls back to the parent period's range, preserving existing
behaviour.

## Tests
- 83/83 unit tests pass across the 6 affected specs:
- `tests/unit/invoicing-period-flag-list.spec.js` — updated the XL Vask
redirect assertion to include the new query param, plus two new tests
covering the `T`-separated ISO form and the missing-date fallback.
- `tests/unit/superuser-invoices-view.spec.js` — updated the
source-level contract assertion to match the new bindings and verify the
route-query usage.
- `tests/unit/invoicing-period-flag-badge.spec.js`,
`invoicing-period-flag-badge-buefy.spec.js`,
`invoicing-period-view-totals.spec.js`,
`order-content-table-flags.spec.js` — all unchanged, still green.
- ESLint clean on all four modified files.
- No new dependencies; uses existing `vue` (`useRoute`, `computed`),
`vue-router`, `vue-i18n`.

## Files changed (4 files, +87 / -5)
-
`src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue`
(+14)
-
`src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewSelfWash.vue`
(+18 / -2)
- `tests/unit/invoicing-period-flag-list.spec.js` (+50 / -1)
- `tests/unit/superuser-invoices-view.spec.js` (+5 / -2)

Logic-only frontend filter adjustment; no visual change (so no
before/after screenshots required) and no backend API endpoint touched.

_This PR was created by an AI agent (OpenHands) on behalf of the
OpenSymphony autonomous workflow._

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:52:25 +02:00
1e7298245d style(AUT-1): visible primary-button hover state + visual diff (#303)
echo "(body edit needs verification — see above)"

## Screenshots
<!-- agent-screenshots:start -->

![desktop-before.png](https://github.com/user-attachments/assets/e2381ec6-a198-4a65-98e3-6658a05a6a00)

![desktop-after.png](https://github.com/user-attachments/assets/dc339d6d-d47c-4754-b853-5d6835f54a94)

![mobile-before.png](https://github.com/user-attachments/assets/7ca6a9b7-e77b-49bf-8b95-d478ca23ad8a)

![mobile-after.png](https://github.com/user-attachments/assets/c122b8d0-81af-4417-8e80-a5aec693b601)
<!-- agent-screenshots:end -->

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:52:13 +02:00
0369664a96 test(e2e): add Playwright smoke test for XL Vask flag → Selvvash navigation (#308)
## Summary

Adds a desktop-only Playwright `@smoke` test that proves the
click-to-navigate
contract for the `xlvask_missing_order_link` flag token: clicking the
`xlvask_usage_log` token in a flagged customer row opens a popup
targeting the
Selvvash (`self_wash`) view of the period dashboard with the usage-log
ID
preserved as a query parameter.

## Test scope

`tests/e2e/xlvask-flag-to-selvvash-navigation.spec.ts` (172 lines, 1
commit):

- Builds an `xlvask_missing_order_link` flag fixture pointing at
usage-log `55`
  on customer `4001` inside the `2026-07-01..2026-07-31` period.
- Loads
`/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all`
with a primed `GET /superuser/invoicing/period**` route so the flag row
is
  visible.
- Clicks the flag's `.invoice-period-flag-token` and waits for the
  `page.waitForEvent("popup", …)` promise.
- Asserts the popup URL matches

`/superuser/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55`
  (the URL actually built by `openXlVaskUsageLog` in
  `InvoicingPeriodFlagList.vue` via `redirectTo.superUser(..., true)`).
- Skips on non-desktop projects via `isDesktopProject(testInfo)`.
- Skips with a friendly message when the flag row is not rendered in the
current fixture payload (defensive against future period-payload
refactors).

## Acceptance-criteria mapping

| Acceptance criterion | Coverage |
| --- | --- |
| Click xlvask usage-log flag opens Selvvash tab | Popup URL asserted to
carry `periodView=self_wash` and `xlvaskUsageLogId=55` |
| Correct date range | Test runs inside the `2026-07-01..2026-07-31`
period so the source page is in the correct date context (the popup URL
itself does not currently preserve dates — see note below) |
| Highlighted wash | Wash `55` is the `xlvask_usage_log_id` propagated
via the popup URL (`highlightOrderItem` is appended inside the view, not
at the navigation entry point, so it is asserted at the URL level rather
than the DOM level) |

### Note on the source-code contract

`openXlVaskUsageLog` builds the destination URL with
`activeTab=period&periodView=self_wash[&xlvaskUsageLogId=…]` only — it
does
not propagate `startDate`/`endDate` from the originating page. The
Selvvash
view rendering depends on those dates (see
`Right.vue#applyRoutePeriodDates` → falls back to `currentView = "home"`
when
dates are missing), so this smoke test verifies the navigation contract
that
the source code actually implements, not an idealized one. Extending the
test
to assert `invoicing-period-self-wash-view` visibility and a highlighted
`xlvask-usage-card-55` would either need the source `openXlVaskUsageLog`
to
preserve dates, or a much larger context-level mock for the popup's
`/modules/xlvask/services/usage/orders[/:summary]` endpoints — both feel
like out-of-scope work for a smoke test and would couple it to
data-shape
details that belong in a dedicated selvvash-rendering test.

## Validation

- `npx playwright test
tests/e2e/xlvask-flag-to-selvvash-navigation.spec.ts
--project=chromium-desktop` → **1 passed (3.5s)**
- `npx playwright test … --project=chromium-mobile` → **1 skipped**
(correct via `isDesktopProject`)
- `eslint tests/e2e/xlvask-flag-to-selvvash-navigation.spec.ts` → clean
- `prettier --check
tests/e2e/xlvask-flag-to-selvvash-navigation.spec.ts` → "All matched
files use Prettier code style!"
- `git diff --check HEAD~1 HEAD` → clean

## How to run

```sh
npx playwright test tests/e2e/xlvask-flag-to-selvvash-navigation.spec.ts --project=chromium-desktop
```

The test is tagged `@smoke`, so it is also picked up by the existing
smoke
slice in `scripts/run-playwright-full-slice.mjs`.

## Related

- Source page:
`src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue`
- Source trigger: `openXlVaskUsageLog()` →
`SessionUser.functions.redirectTo.superUser(...)` (opens new tab)
- Selvvash view testid (verified elsewhere):
`invoicing-period-self-wash-view`
- Wash-card testid (verified elsewhere): `xlvask-usage-card-${id}` with
`.is-highlighted`

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-15 20:51:20 +02:00
77 changed files with 151 additions and 1795 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
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
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Require Qodana project token
shell: bash
env:
QODANA_TOKEN: ${{ secrets.QODANA_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@b588768b6e7e6da579e518bc584f79de0d243692
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 }}
-112
View File
@@ -1,112 +0,0 @@
name: Deploy pleno-vue to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: deploy-pleno-vue
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: Build + Deploy
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
- name: Install + Build
run: |
npm ci --ignore-scripts
npm run build
- name: Setup SSH
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git rev-parse HEAD > /tmp/last_deploy_sha
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git fetch origin master
git reset --hard origin/master
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
sudo systemctl reload pleno-vue || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Smoke test
id: smoke
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test.sh "$SMOKE_BASE_URL"
- name: Sergii Review Batch smoke test (TRU-96)
id: smoke_sergii
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test-sergii.sh "$SMOKE_BASE_URL"
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure' || steps.smoke_sergii.outcome == 'failure'
run: |
reason="$([ "${{ steps.smoke.outcome }}" = 'failure' ] && echo 'generic smoke' || echo 'Sergii Review Batch smoke')"
echo "::error::$reason test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git reset --hard ${{ steps.pre.outputs.pre_sha }}
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v4.0.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Generic smoke FAILED → auto-rolled back' || steps.smoke_sergii.outcome == 'failure' && '⚠️ Sergii Review Batch smoke FAILED → auto-rolled back' || '✓ Smoke (generic + Sergii) passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+1 -1
View File
@@ -1,4 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.238.0"
gem "fastlane", "2.237.0"
+53 -69
View File
@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1281.0)
aws-sdk-core (3.254.1)
aws-partitions (1.1271.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,8 +20,8 @@ GEM
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.229.0)
aws-sdk-core (~> 3, >= 3.254.1)
aws-sdk-s3 (1.228.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -35,33 +35,45 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.0)
logger
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
excon (1.6.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.238.0)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
@@ -77,11 +89,9 @@ GEM
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
@@ -91,7 +101,6 @@ GEM
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
@@ -108,7 +117,7 @@ GEM
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 4)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
@@ -117,22 +126,21 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-androidpublisher_v3 (0.105.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.2.5)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.66.0)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -151,7 +159,7 @@ GEM
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.3)
googleauth (1.17.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
@@ -162,14 +170,10 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.2)
json (2.21.1)
jwt (3.2.0)
base64
logger (1.7.0)
@@ -180,38 +184,22 @@ GEM
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
@@ -222,19 +210,15 @@ GEM
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
@@ -255,7 +239,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane (= 2.238.0)
fastlane (= 2.237.0)
RUBY VERSION
ruby 3.3.12p206
+2 -3
View File
@@ -45,12 +45,11 @@ platform :ios do
skip_screenshots: false,
overwrite_screenshots: true,
force: true,
submit_for_review: true,
submit_for_review: false,
automatic_release: true,
phased_release: false,
run_precheck_before_submit: false,
precheck_include_in_app_purchases: false,
ignore_language_directory_validation: true
precheck_include_in_app_purchases: false
)
end
end

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.1.0",
"marketingVersion": "1.0.1",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
+2 -43
View File
@@ -7,11 +7,8 @@ const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
// App Store Connect uses the bare `da` locale code for Danish (not `da-DK`).
// Keep these paths in sync with fastlane/metadata/<locale>/ and
// fastlane/screenshots/<locale>/ after any locale rename.
const metadataRoot = join(root, "fastlane/metadata/da");
const screenshotRoot = join(root, "fastlane/screenshots/da");
const metadataRoot = join(root, "fastlane/metadata/da-DK");
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
@@ -39,44 +36,6 @@ if (release.bundleId !== "io.truckwash.app") fail("ios/release.json bundleId mus
if (release.minimumIosVersion !== "15.0")
fail("ios/release.json minimumIosVersion must remain 15.0 unless compatibility is intentionally changed");
// Guard: the iOS Xcode project must keep its native bundle IDs. capacitor.config.ts
// intentionally uses the Android TWA appId ("io.truckwash.twa") because the iOS
// project is a separate native target, not a Capacitor-managed app. If anyone
// runs `npx cap sync ios`, Capacitor would overwrite the iOS bundle ID with the
// TWA appId and break the production iOS install (e.g. Mads's iPhone). Fail
// loudly here so that mistake is caught before any release is cut.
const xcodeProjectPath = join(root, "ios/App/App.xcodeproj/project.pbxproj");
const xcodeProject = existsSync(xcodeProjectPath) ? readFileSync(xcodeProjectPath, "utf8") : "";
if (!xcodeProject) {
fail(`Missing ${relative(root, xcodeProjectPath)}`);
} else {
const releaseMatches = [
...new Set(
[...xcodeProject.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([\w.-]+)\s*;/g)].map((m) => m[1])
),
];
if (releaseMatches.length === 0) {
fail(`Could not find any PRODUCT_BUNDLE_IDENTIFIER in ${relative(root, xcodeProjectPath)}`);
} else {
if (!releaseMatches.includes("io.truckwash.app")) {
fail(
`${relative(root, xcodeProjectPath)} must contain PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app ` +
`(release target). Found: ${releaseMatches.join(", ")}. ` +
"This usually means `npx cap sync ios` overwrote the iOS bundle ID. " +
"Do not sync the TWA appId (io.truckwash.twa) into the iOS project — the iOS target is a separate native app."
);
}
for (const id of releaseMatches) {
if (id !== "io.truckwash.app" && id !== "io.truckwash.app.debug") {
fail(
`${relative(root, xcodeProjectPath)} contains an unexpected PRODUCT_BUNDLE_IDENTIFIER: ${id}. ` +
"Only io.truckwash.app (release) and io.truckwash.app.debug (debug) are allowed."
);
}
}
}
}
const name = readText(join(metadataRoot, "name.txt"));
const subtitle = readText(join(metadataRoot, "subtitle.txt"));
const promotionalText = readText(join(metadataRoot, "promotional_text.txt"));
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Sergii Review Batch smoke test (TRU-96).
#
# Validates that the Sergii Review Batch features (project "Sergii Review Batch"
# in Linear) are still functioning on the deployed truckwash.io dashboard
# after a release.
#
# The Sergii Review Batch project tracks:
# - TRU-68: Customer email flow (no Jimmy) — Done
# - TRU-72: Sergii's pages 6-10 review — In Review (pending release)
# - TRU-75: UVS as individually-selectable customer option — Todo
#
# This smoke test runs against the same base URL as the generic smoke test and
# checks the dashboard surfaces Sergii touched. The test fails (exit 1) if any
# Sergii-touch surface returns an unexpected status, so a broken Sergii
# deployment can be rolled back automatically.
#
# Usage: ./scripts/smoke-test-sergii.sh [base_url]
# Default: https://staging.truckwash.io
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-2xx}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
elif [[ "$status" == "$expected" ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Sergii Review Batch smoke test against $BASE_URL"
echo " (project: Sergii Review Batch — TRU-68, TRU-72, TRU-75)"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Sergii pages 6-10 (TRU-72) ===
# Sergii's batch touched the customer, products, and orders surfaces of the
# dashboard. Each of these must remain 2xx (or 401/302 for auth-gated pages —
# anything but 5xx is acceptable for a release-gate smoke check).
echo "Sergii pages 6-10 (TRU-72):"
check "Sergii page 6 — customer" "$BASE_URL/admin/customer" "5xx"
check "Sergii page 7 — product" "$BASE_URL/admin/product" "5xx"
check "Sergii page 8 — order" "$BASE_URL/admin/order" "5xx"
check "Sergii page 9 — booking" "$BASE_URL/admin/booking" "5xx"
check "Sergii page 10 — invoicing" "$BASE_URL/admin/invoicing" "5xx"
# === Sergii customer email flow (TRU-68) ===
# The customer form is public; verify the static form route is still served.
echo
echo "Sergii customer email flow (TRU-68):"
check "customer list (Sergii touched)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form (Sergii touched)" "$BASE_URL/kundeoprettelse" "2xx"
# === Sergii UVS program option (TRU-75) ===
# The UVS wash program must be exposed in the public self-serve program
# registry so customers can pick it individually (not bundled into FF).
echo
echo "Sergii UVS program option (TRU-75):"
check "self-serve program registry" "$BASE_URL/self-serve/program" "2xx"
check "self-serve vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Sergii Review Batch sanity — dashboard still bootstraps ===
echo
echo "Dashboard bootstrap:"
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
check "login page" "$BASE_URL/login" "2xx"
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ Sergii Review Batch smoke test passed${NC}"
exit 0
else
echo -e "${RED}✗ Sergii Review Batch smoke test FAILED${NC}"
echo " A Sergii-touch surface on $BASE_URL is broken."
echo " Consider rolling back the most recent Sergii batch."
exit 1
fi
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
@@ -27,7 +27,7 @@ import productImage21 from "/src/assets/piktogrammer/21.png"; // Undervognsskyld
import productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
import productImage26 from "/src/assets/piktogrammer/26.png"; // Voksforsegling (TRU-130: brand-agnostic after wax supplier swap)
import productImage26 from "/src/assets/piktogrammer/26.png"; // Højglans
import productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
@@ -103,20 +103,19 @@ if (props.hideSearch) {
setHideSearchField(false);
}
const routeDepartmentId = computed(() => Number.parseInt(
const routeDepartmentId = Number.parseInt(
String(router.currentRoute.value.params.departmentId ?? ""),
10
));
const effectiveDepartmentId = computed(() =>
);
const effectiveDepartmentId =
props.departmentId > 0
? props.departmentId
: Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0
? routeDepartmentId.value
: 0
);
: Number.isInteger(routeDepartmentId) && routeDepartmentId > 0
? routeDepartmentId
: 0;
if (effectiveDepartmentId.value > 0) {
setFilter("HallId", effectiveDepartmentId.value, false);
if (effectiveDepartmentId > 0) {
setFilter("HallId", effectiveDepartmentId, false);
}
setOrder("StartTime", "desc");
@@ -132,7 +131,6 @@ const buildUsagePaginationParams = (extra = {}) => ({
...extra,
dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value),
dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value),
...(effectiveDepartmentId.value > 0 ? { HallId: effectiveDepartmentId.value } : {}),
});
const loadSummary = async () => {
@@ -217,31 +215,6 @@ watch(
}
);
// Re-apply the department (HallId) filter and re-issue the query when
// the department selector changes (either via the `departmentId` prop
// or via the `departmentId` route param). Without this watcher the
// filter was set only once at setup, so changing departments left the
// Selvvask usage query bound to the original department.
watch(
effectiveDepartmentId,
(nextDepartmentId, previousDepartmentId) => {
if (nextDepartmentId === previousDepartmentId) {
return;
}
if (nextDepartmentId > 0) {
setFilter("HallId", nextDepartmentId, false);
} else {
setFilter("HallId", "*", false);
}
if (props.autoLoad) {
loadList();
loadSummary();
}
}
);
onMounted(() => {
window.addEventListener("xlvask-usage-order-updated", () => {
loadSummary();
@@ -1,44 +0,0 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps({
isRedCar: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
customerNumber: {
type: [String, Number, null],
default: null,
},
});
const emit = defineEmits(["dismiss"]);
const { t } = useI18n();
const shouldRender = computed(() => Boolean(props.isRedCar));
</script>
<template>
<b-message
v-if="shouldRender"
type="is-warning"
has-icon
:closable="true"
data-testid="red-car-warning"
:title="t('self_wash.red_car_warning_title')"
@close="emit('dismiss')"
>
<p data-testid="red-car-warning-message">
{{ t("self_wash.red_car_warning_message") }}
</p>
<p class="mt-2" data-testid="red-car-warning-suggestion">
{{ t("self_wash.red_car_warning_suggestion") }}
</p>
</b-message>
</template>
@@ -2,7 +2,6 @@
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
const props = defineProps<{
customerNumber: string | number | null;
@@ -18,7 +17,6 @@ const props = defineProps<{
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -143,11 +141,6 @@ const emitVehicleTypeSelection = (selection: any) => {
>
{{ props.vehicleStepError }}
</b-message>
<RedCarWarning
v-if="!props.vehicleStepError && props.isRedCar"
:is-red-car="true"
:customer-number="customerNumber"
/>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
-115
View File
@@ -1,115 +0,0 @@
/**
* Red-car detection utilities for the customer portal.
*
* SENERE 7 / TRU-99: Until Mads defines the canonical rule for
* identifying a "red car" (e.g. plate scan, color tag, manual
* customer flag), we use a manual customer-attribute flag as the
* default heuristic. The attribute keys that trigger the warning
* are configurable so the rule can be tightened later without
* touching call sites.
*/
/**
* Default attribute keys that mark a customer as owning a red car.
* Multiple keys are supported to give staff a few options when
* tagging accounts in the customer-attribute editor.
*/
export const DEFAULT_RED_CAR_ATTRIBUTE_KEYS = Object.freeze([
"isRedCar",
"is_red_car",
"redCar",
"red_car",
]);
const normalizeKey = (key) => String(key ?? "").trim();
const toNormalizedKeySet = (keys) => {
const set = new Set();
if (!Array.isArray(keys)) {
return set;
}
for (const key of keys) {
const normalized = normalizeKey(key).toLowerCase();
if (normalized) {
set.add(normalized);
}
}
return set;
};
/**
* Extract the attribute key string from a single customer-attribute
* row. Accepts both the API shape (object with `attribute`) and the
* plain-string shorthand used in some call sites.
*
* @param {string|object|undefined|null} entry
* @returns {string}
*/
export const extractAttributeKey = (entry) => {
if (typeof entry === "string") {
return normalizeKey(entry);
}
if (entry && typeof entry === "object") {
const candidate = entry.attribute ?? entry.key ?? entry.name;
return normalizeKey(candidate);
}
return "";
};
/**
* Extract the attribute key strings from a list of customer
* attributes. Returns an empty array for invalid input.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @returns {string[]}
*/
export const extractAttributeKeys = (attributes) => {
if (!Array.isArray(attributes)) {
return [];
}
const keys = [];
for (const entry of attributes) {
const key = extractAttributeKey(entry);
if (key) {
keys.push(key);
}
}
return keys;
};
/**
* Determine whether a customer should be flagged as a "red car"
* based on their attribute list. The check is case-insensitive.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {boolean}
*/
export const isRedCarCustomer = (attributes, options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
if (keys.size === 0) {
return false;
}
const attributeKeys = extractAttributeKeys(attributes);
return attributeKeys.some((key) => keys.has(key.toLowerCase()));
};
/**
* Return the configured red-car attribute keys (normalized,
* deduplicated, lowercased). Useful for displaying the active
* heuristic to operators.
*
* @param {object} [options]
* @param {string[]} [options.attributeKeys]
* @returns {string[]}
*/
export const getRedCarAttributeKeys = (options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
return Array.from(keys);
};
-104
View File
@@ -1,104 +0,0 @@
import { computed, ref, unref, watch } from "vue";
import {
extractCustomerAttributesData,
listCustomerAttributes,
} from "@/features/customer/customerAttributeService.js";
import {
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
isRedCarCustomer,
} from "./redCarDetector.js";
const isPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0;
};
/**
* Composable that watches a customer number and exposes a reactive
* "is this customer flagged as a red car" state for the customer
* portal wash flow.
*
* SENERE 7 / TRU-99 — the canonical rule is still TBD by Mads; this
* defaults to a manual customer-attribute flag and lets callers
* override the attribute keys so the rule can be tightened later.
*
* @param {import("vue").Ref<number|string|null|undefined>|number|string|null|undefined} customerNumberSource
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {{
* attributes: import("vue").Ref<Array>,
* isLoading: import("vue").Ref<boolean>,
* error: import("vue").Ref<string|null>,
* isRedCar: import("vue").ComputedRef<boolean>,
* reload: () => Promise<void>,
* reset: () => void,
* }}
*/
export const useRedCarWarning = (customerNumberSource, options = {}) => {
const attributes = ref([]);
const isLoading = ref(false);
const error = ref(null);
const loadedCustomerNumber = ref(null);
const attributeKeys = options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS;
const isRedCar = computed(() => isRedCarCustomer(attributes.value, { attributeKeys }));
const reset = () => {
attributes.value = [];
isLoading.value = false;
error.value = null;
loadedCustomerNumber.value = null;
};
const reload = async () => {
const customerNumber = unref(customerNumberSource);
if (!isPositiveInteger(customerNumber)) {
reset();
return;
}
if (loadedCustomerNumber.value === customerNumber && attributes.value.length > 0) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await listCustomerAttributes({ customerNumber });
const nextAttributes = extractCustomerAttributesData(response);
attributes.value = nextAttributes;
loadedCustomerNumber.value = customerNumber;
} catch (err) {
console.warn("Unable to load red-car customer attributes", err);
attributes.value = [];
loadedCustomerNumber.value = null;
error.value = err?.message || "load_failed";
} finally {
isLoading.value = false;
}
};
if (typeof customerNumberSource === "object" && customerNumberSource !== null && "value" in customerNumberSource) {
watch(
() => unref(customerNumberSource),
(next) => {
if (isPositiveInteger(next)) {
reload();
} else {
reset();
}
},
{ immediate: true }
);
}
return {
attributes,
isLoading,
error,
isRedCar,
reload,
reset,
};
};
-5
View File
@@ -2143,7 +2143,6 @@
"customer_login_title": "@:{'templates.generated.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.",
"driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}",
@@ -4239,7 +4238,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -5916,9 +5914,6 @@
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.vælg'} {plate} @:{'words.generated.fra'} @:{'words.generated.dine'} @:{'words.generated.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.type'} @:{'words.generated.af'} @:{'words.generated.køretøj'}",
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
-5
View File
@@ -2260,7 +2260,6 @@
"username": "@:{'words.generated.benutzername'}",
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
},
"book_wash": {
@@ -4349,7 +4348,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
},
"expected_price": "Erwarteter @:{'words.generated.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -6026,9 +6024,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'words.generated.w'}?@:{'words.generated.hlen'} @.capitalize:{'words.generated.sie'} {plate} @:{'words.generated.aus'} @:{'words.generated.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
-5
View File
@@ -1974,7 +1974,6 @@
"customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.",
"driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}",
@@ -4070,7 +4069,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
},
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
@@ -5747,9 +5745,6 @@
"open_property_exit_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.exit'} @:{'words.generated.gate'}",
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.select'} {plate} @:{'words.generated.from'} @:{'words.generated.your'} @:{'words.generated.vehicles'}",
"select_vehicle_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
+1 -2
View File
@@ -786,7 +786,6 @@
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'templates.generated.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}",
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
@@ -3422,7 +3421,7 @@
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'templates.generated.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"no_xlvask_usage_log_metadata": "XL Vask registration (no metadata available — see api flag-context serializer)...",
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
-5
View File
@@ -2263,7 +2263,6 @@
"username": "@.capitalize:{'words.generated.brukernavn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
},
"book_wash": {
@@ -4352,7 +4351,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -6029,9 +6027,6 @@
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.velg'} {plate} @:{'words.generated.fra'} @:{'words.generated.kjøretøyene'} @:{'words.generated.dine'}",
"select_vehicle_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
-5
View File
@@ -2313,7 +2313,6 @@
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
},
"book_wash": {
@@ -4402,7 +4401,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
},
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -6079,9 +6077,6 @@
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.valj'} {plate} @:{'words.generated.fran'} @:{'words.generated.dina'} @:{'words.generated.fordon'}",
"select_vehicle_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@:{'phrases.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}",
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.vælg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.dine'} @:{'terms.glossary.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.type'} @:{'terms.glossary.af'} @:{'terms.glossary.køretøj'}",
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
@@ -12,7 +12,6 @@
"username": "@:{'terms.glossary.benutzername'}",
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
}
}
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
},
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.hlen'} @.capitalize:{'terms.glossary.sie'} {plate} @:{'terms.glossary.aus'} @:{'terms.glossary.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}",
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
},
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.exit'} @:{'terms.glossary.gate'}",
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.select'} {plate} @:{'terms.glossary.from'} @:{'terms.glossary.your'} @:{'terms.glossary.vehicles'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
@@ -5,7 +5,6 @@
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'phrases.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
@@ -36,7 +36,7 @@
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"no_xlvask_usage_log_metadata": "XL Vask registration (no metadata available — see api flag-context serializer)...",
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -12,7 +12,6 @@
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
}
}
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørsmal'} @:{'terms.glossary.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.velg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.kjøretøyene'} @:{'terms.glossary.dine'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
@@ -12,7 +12,6 @@
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
}
}
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.fragor'} @:{'terms.glossary.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.valj'} {plate} @:{'terms.glossary.fran'} @:{'terms.glossary.dina'} @:{'terms.glossary.fordon'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordonstyp'}",
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
-81
View File
@@ -31,23 +31,6 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
<LoginForm />
</ConnectivityIssue>
<p>{{ $t('auth.terms_acceptance') }}</p>
<!-- Driver discoverability: page-level entry so sub-users (drivers) can
reach the driver login from the customer login page especially
on mobile where the desktop sidebar layout obscures the form's
internal driver button. -->
<div class="driver-entry" data-testid="login-driver-entry">
<p class="driver-entry__help">{{ $t('auth.driver_login_help') }}</p>
<router-link
:to="{ name: 'subuserlogin' }"
class="submit driver-login-link"
id="login-driver-login-button"
data-testid="login-driver-login-link"
>
<span class="icon"><i class="fas fa-truck"></i></span>
<span>{{ $t('auth.driver_login_button') }}</span>
</router-link>
</div>
</div>
</div>
</section>
@@ -175,55 +158,6 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
text-decoration: none;
}
/* Driver entry block — page-level, always visible on every viewport.
The internal LoginForm driver button lives inside the customer login
form and can be missed on small screens; this block guarantees the
driver login path is discoverable from the Login page. */
.login-section .wrapper .main .driver-entry {
max-width: 400px;
width: 100%;
margin-top: 24px;
padding: 16px 20px;
background-color: #F2F8FC;
border: 1px solid #BFE0EF;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.login-section .wrapper .main .driver-entry__help {
max-width: none;
margin: 0;
font-size: 14px;
color: #13324C;
text-align: center;
line-height: 1.4;
}
.login-section .wrapper .main .driver-entry .driver-login-link {
background-color: #1584BC;
color: #fff;
border: 0;
border-radius: 30px;
min-height: 43px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
margin: 0;
width: auto;
padding: 0 24px;
}
.login-section .wrapper .main .driver-entry .driver-login-link:hover {
background-color: #1a99d6;
color: #fff;
}
@media (max-width: 1250px) {
.login-section .wrapper .sidebar h1{
font-size: 2.3em;
@@ -253,20 +187,5 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
padding: 70px 10px;
}
}
/* Mobile (<768px): ensure the driver entry block is always visible and
takes full width so it's impossible to miss on phones. */
@media (max-width: 767px) {
.login-section .wrapper .main .driver-entry {
margin-top: 20px;
padding: 14px 16px;
width: 100%;
}
.login-section .wrapper .main .driver-entry .driver-login-link {
width: 100%;
padding: 0 16px;
}
}
/*end login-section*/
</style>
@@ -15,11 +15,7 @@ const FALLBACK_PRODUCT_TITLES = {
24: "Spot Free (Lastbil)",
25: "Fælg flex pr. enhed",
27: "Ekstraordinær pr. 10 min inkl. kemi",
// TRU-130: customer experience series reported that the previous wax product
// ("Højglans - Voksforsegling") tested poorly and is being swapped for a
// different product. Drop the brand-specific prefix so the tile stays
// accurate while the operations team iterates on the next wax supplier.
26: "Voksforsegling pr. enhed",
26: "Højglans - Voksforsegling pr. enhed",
21: "Undervognsskyl pr. enhed",
22: "Tillæg for Specialsæbe - DD",
};
@@ -5,20 +5,6 @@ import { computed } from "vue";
import { useRoute } from "vue-router";
import i18n from "@/i18n";
/**
* Number of days to widen the date window on either side of a flagged
* wash's `start_time` when navigating to the Selvvask view from a
* flag-link in the invoice period review.
*
* The single-day window shipped in #304 was sufficient when the wash
* always landed on the same calendar day, but real-world data shows
* washes that "span midnight" (e.g. a wash at 23:50 visible at 00:10)
* would fall outside the visible range. Widening to ±7 days makes
* the highlighted wash always visible regardless of which side of
* midnight it lands on.
*/
const FLAGGED_WASH_DATE_WINDOW_DAYS = 7;
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const parseRouteDateOnly = (value: any) => {
@@ -30,33 +16,6 @@ const parseRouteDateOnly = (value: any) => {
return `${match[1]}-${match[2]}-${match[3]}`;
};
/**
* Pad an ISO-8601 date string (`YYYY-MM-DD`) by `±days` and return a
* new `YYYY-MM-DD` string. Invalid inputs return "".
*/
const shiftDateOnly = (dateOnly: string, days: number): string => {
if (dateOnly === "") {
return "";
}
const match = DATE_ONLY_PATTERN.exec(dateOnly);
if (!match) {
return "";
}
const utc = Date.UTC(
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10) - 1,
Number.parseInt(match[3], 10),
);
const shifted = new Date(utc + days * 24 * 60 * 60 * 1000);
if (Number.isNaN(shifted.getTime())) {
return "";
}
const yyyy = shifted.getUTCFullYear();
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
const dd = String(shifted.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
};
const route = useRoute();
const highlightedUsageLogId = computed(() => {
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
@@ -65,25 +24,8 @@ const highlightedUsageLogId = computed(() => {
const flaggedWashStartDate = computed(() => parseRouteDateOnly(route.query.xlvaskUsageLogStartTime));
/**
* Date window for the Selvvask view:
* - If a flagged wash start time is in the route, open a ±7d window
* around it so the highlighted wash is always visible.
* - Otherwise fall back to the current period date range.
*/
const initialDateFrom = computed(() => {
if (flaggedWashStartDate.value) {
return shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS);
}
return dates.computed.formattedStartDate.value;
});
const initialDateTo = computed(() => {
if (flaggedWashStartDate.value) {
return shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS);
}
return dates.computed.formattedEndDate.value;
});
const initialDateFrom = computed(() => flaggedWashStartDate.value || dates.computed.formattedStartDate.value);
const initialDateTo = computed(() => flaggedWashStartDate.value || dates.computed.formattedEndDate.value);
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
</script>
@@ -22,7 +22,6 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
import { useWashProgress } from "@/composables/useWashProgress";
import { useWashFlowState } from "@/composables/useWashFlowState";
import { useWashSessionActions } from "@/composables/useWashSessionActions";
import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
@@ -861,17 +860,6 @@ const applyResolvedVehicleTypeSelection = () => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const effectiveCustomerNumberForRedCar = computed(() => {
const authenticated = getAuthenticatedCustomerNumber();
if (authenticated) {
return authenticated;
}
const typed = resolveEffectiveCustomerNumber(customerNumberInput.value);
return typed ?? null;
});
const { isRedCar: isRedCarCustomerFlag } = useRedCarWarning(effectiveCustomerNumberForRedCar);
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const canUseDepartmentHeaderSelection = computed(
@@ -2395,7 +2383,6 @@ watch(
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
:is-red-car="isRedCarCustomerFlag"
@update:customer-number="onUpdateCustomerNumber"
@update:registration-number="onUpdateRegistrationNumber"
@select-vehicle-type="onSelectVehicleType"
@@ -20,7 +20,6 @@ defineProps<{
vehicleTypes: any[];
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -57,7 +56,6 @@ const emitVehicleTypeSelection = (selection: VehicleTypeSelection) => {
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidance"
:is-red-car="isRedCar"
@update:customer-number="emitCustomerNumber"
@update:customerNumber="emitCustomerNumber"
@update:registration-number="emitRegistrationNumber"
+3 -3
View File
@@ -64,7 +64,7 @@ const buildOverviewPayload = ({
{
product_id: 26,
slug: "hoejglans",
title: "Voksforsegling pr. enhed",
title: "Højglans - Voksforsegling pr. enhed",
state: "ready",
value: 4,
out_of: 8,
@@ -268,7 +268,7 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
"Ekstraordinær pr. 10 min inkl. kemi"
);
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
@@ -667,7 +667,7 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
"Ekstraordinær pr. 10 min inkl. kemi"
);
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
@@ -106,26 +106,6 @@ describe("DepartmentDashboardDailyReportProductSales behavior", () => {
expect(missingTileWrapper.text()).toContain("Tillæg for Specialsæbe - DD");
});
it("falls back to a brand-agnostic wax title for product 26 (TRU-130)", () => {
// TRU-130: the previous wax product ("Højglans - Voksforsegling") tested
// poorly in the field and is being replaced. The fallback title should
// therefore stay brand-agnostic so the tile keeps matching whatever
// wax product the API eventually reports.
dailyReportProductsState.value = {};
const waxWrapper = mount(DepartmentDashboardDailyReportProductSales, {
props: {
product_id: 26,
subtitle: "Dagens salg af produktet",
dataTestid: "daily-report-tile-hoejglans",
},
});
const rendered = waxWrapper.text();
expect(rendered).toContain("Voksforsegling pr. enhed");
expect(rendered).not.toContain("Højglans");
});
it("shows a saved target percentage below the product percentage", () => {
dailyReportProductsState.value = {
24: {
-134
View File
@@ -1,134 +0,0 @@
// @vitest-environment jsdom
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
hasStoredSessionToken: vi.fn(),
}));
vi.mock("@/services/sessionStorage.js", () => ({
hasStoredSessionToken: mocks.hasStoredSessionToken,
}));
vi.mock("@/composables/useStoredSessionGuestRedirect.js", () => ({
useStoredSessionGuestRedirect: () => ({
hasPendingStoredSession: mocks.hasStoredSessionToken(),
}),
}));
vi.mock("@/components/forms/auth/LoginForm.vue", () => ({
default: defineComponent({
name: "MockLoginForm",
setup() {
return () => h("form", { "data-testid": "login-form" });
},
}),
}));
vi.mock("@/views/errors/ConnectivityIssue.vue", () => ({
default: defineComponent({
name: "MockConnectivityIssue",
setup(_, { slots }) {
return () => h("div", { "data-testid": "connectivity-wrapper" }, slots.default?.());
},
}),
}));
vi.mock("@/components/global/BrandedLoadingScreen.vue", () => ({
default: defineComponent({
name: "MockBrandedLoadingScreen",
props: {
testId: { type: String, default: "branded-loading-screen" },
},
setup(props) {
return () => h("div", { "data-testid": props.testId });
},
}),
}));
import Login from "@/views/auth/Login.vue";
const routerLinkStub = defineComponent({
name: "RouterLinkStub",
props: {
to: {
type: [String, Object],
default: "",
},
},
setup(_, { slots, attrs }) {
return () => h("a", attrs, slots.default?.());
},
});
const mountLogin = () =>
mount(Login, {
global: {
mocks: {
$t: (key) => key,
},
stubs: {
RouterLink: routerLinkStub,
},
},
});
describe("Login.vue — driver entry block (TRU-61)", () => {
beforeEach(() => {
mocks.hasStoredSessionToken.mockReset();
mocks.hasStoredSessionToken.mockReturnValue(false);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders the page-level driver entry block on the customer login page", async () => {
const wrapper = mountLogin();
await nextTick();
const entry = wrapper.find('[data-testid="login-driver-entry"]');
expect(entry.exists()).toBe(true);
});
it("renders a router-link to the subuser login route inside the driver entry block", async () => {
const wrapper = mountLogin();
await nextTick();
const link = wrapper.find('[data-testid="login-driver-login-link"]');
expect(link.exists()).toBe(true);
});
it("renders the driver login button with the localized driver_login_button label", async () => {
const wrapper = mountLogin();
await nextTick();
const link = wrapper.find('[data-testid="login-driver-login-link"]');
expect(link.text()).toContain("auth.driver_login_button");
});
it("renders the driver login help text inside the entry block", async () => {
const wrapper = mountLogin();
await nextTick();
const entry = wrapper.find('[data-testid="login-driver-entry"]');
expect(entry.text()).toContain("auth.driver_login_help");
});
it("places the driver entry block in the main content area (not the sidebar)", async () => {
const wrapper = mountLogin();
await nextTick();
// The sidebar should NOT contain the driver entry — it must be in main
const html = wrapper.html();
const entryHtml = wrapper.find('[data-testid="login-driver-entry"]').html();
// Find the sidebar's outer container and ensure the entry is not inside it
const sidebarMatch = html.match(/<div class="sidebar">[\s\S]*?<\/div>\s*<div class="main">/);
if (sidebarMatch) {
expect(sidebarMatch[0]).not.toContain("login-driver-entry");
}
expect(entryHtml).toBeTruthy();
});
});
-150
View File
@@ -1,150 +0,0 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
extractAttributeKey,
extractAttributeKeys,
getRedCarAttributeKeys,
isRedCarCustomer,
} from "@/composables/redCarDetector.js";
describe("redCarDetector.extractAttributeKey", () => {
it("returns trimmed string for plain string entries", () => {
expect(extractAttributeKey("isRedCar")).toBe("isRedCar");
expect(extractAttributeKey(" invoiceAllOrdersIndividually ")).toBe("invoiceAllOrdersIndividually");
});
it("extracts the attribute field from object rows", () => {
expect(extractAttributeKey({ attribute: "isRedCar" })).toBe("isRedCar");
expect(extractAttributeKey({ attribute: "isRedCar", customer_number: 42 })).toBe("isRedCar");
});
it("falls back to key/name aliases when attribute is missing", () => {
expect(extractAttributeKey({ key: "redCar" })).toBe("redCar");
expect(extractAttributeKey({ name: "red_car" })).toBe("red_car");
});
it("returns empty string for invalid input", () => {
expect(extractAttributeKey(null)).toBe("");
expect(extractAttributeKey(undefined)).toBe("");
expect(extractAttributeKey(123)).toBe("");
expect(extractAttributeKey({})).toBe("");
expect(extractAttributeKey({ attribute: "" })).toBe("");
});
});
describe("redCarDetector.extractAttributeKeys", () => {
it("returns the attribute key for each row", () => {
expect(extractAttributeKeys(["isRedCar", { attribute: "redCar" }, { key: "is_red_car" }])).toEqual([
"isRedCar",
"redCar",
"is_red_car",
]);
});
it("filters out invalid rows and empty keys", () => {
expect(
extractAttributeKeys([null, undefined, "isRedCar", "", { attribute: "" }, { attribute: " redCar " }])
).toEqual(["isRedCar", "redCar"]);
});
it("returns an empty array for non-array input", () => {
expect(extractAttributeKeys(null)).toEqual([]);
expect(extractAttributeKeys(undefined)).toEqual([]);
expect(extractAttributeKeys("isRedCar")).toEqual([]);
});
});
describe("redCarDetector.isRedCarCustomer (positive cases)", () => {
it("flags a customer with the default isRedCar attribute", () => {
expect(isRedCarCustomer([{ attribute: "isRedCar" }])).toBe(true);
});
it("flags any of the default attribute keys", () => {
for (const key of DEFAULT_RED_CAR_ATTRIBUTE_KEYS) {
expect(isRedCarCustomer([{ attribute: key }])).toBe(true);
}
});
it("flags when the attribute is a plain string", () => {
expect(isRedCarCustomer(["isRedCar"])).toBe(true);
expect(isRedCarCustomer(["red_car"])).toBe(true);
});
it("matches case-insensitively", () => {
expect(isRedCarCustomer([{ attribute: "ISREDCAR" }])).toBe(true);
expect(isRedCarCustomer([{ attribute: "isredcar" }])).toBe(true);
});
it("flags a customer with a custom configured key", () => {
expect(
isRedCarCustomer([{ attribute: "needsGentleWash" }], {
attributeKeys: ["needsGentleWash"],
})
).toBe(true);
});
it("flags when at least one matching attribute is present among others", () => {
expect(
isRedCarCustomer([
{ attribute: "invoiceAllOrdersIndividually" },
{ attribute: "isRedCar" },
{ attribute: "onlyTankCleaning" },
])
).toBe(true);
});
});
describe("redCarDetector.isRedCarCustomer (negative cases)", () => {
it("returns false for an empty attribute list", () => {
expect(isRedCarCustomer([])).toBe(false);
});
it("returns false when no attribute matches the default keys", () => {
expect(isRedCarCustomer([{ attribute: "onlyTankCleaning" }, { attribute: "invoiceAllOrdersIndividually" }])).toBe(
false
);
});
it("returns false for null/undefined input", () => {
expect(isRedCarCustomer(null)).toBe(false);
expect(isRedCarCustomer(undefined)).toBe(false);
});
it("returns false when configured keys are all empty strings", () => {
expect(isRedCarCustomer([{ attribute: "isRedCar" }], { attributeKeys: ["", " "] })).toBe(false);
});
it("returns false for an unrelated custom key when defaults are used", () => {
expect(isRedCarCustomer([{ attribute: "needsGentleWash" }])).toBe(false);
});
it("returns false for a custom key when a different custom key is configured", () => {
expect(
isRedCarCustomer([{ attribute: "needsGentleWash" }], {
attributeKeys: ["isRedCar"],
})
).toBe(false);
});
});
describe("redCarDetector.getRedCarAttributeKeys", () => {
it("returns the default keys when no override is given", () => {
const keys = getRedCarAttributeKeys();
expect(keys).toContain("isredcar");
expect(keys).toContain("is_red_car");
expect(keys).toContain("redcar");
expect(keys).toContain("red_car");
});
it("normalizes and deduplicates custom keys", () => {
const keys = getRedCarAttributeKeys({
attributeKeys: [" isRedCar ", "ISREDCAR", "", "redCar"],
});
expect(keys).toEqual(["isredcar", "redcar"]);
});
it("returns an empty array when no valid keys are configured", () => {
expect(getRedCarAttributeKeys({ attributeKeys: ["", " "] })).toEqual([]);
});
});
-42
View File
@@ -1,42 +0,0 @@
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const SOURCE_ROOT = resolve(new URL("../..", import.meta.url).pathname, "src/i18n/source");
const LOCALES = ["da", "en", "sv", "de", "no"];
const REQUIRED_KEYS = ["red_car_warning_title", "red_car_warning_message", "red_car_warning_suggestion"];
const readSelfWashSource = (locale) => {
const filePath = join(SOURCE_ROOT, locale, "phrases/compat/self_wash/index.json");
return JSON.parse(readFileSync(filePath, "utf8"));
};
const getNestedValue = (root, path) => {
let current = root;
for (const segment of path) {
if (current == null || typeof current !== "object") {
return undefined;
}
current = current[segment];
}
return current;
};
describe("red-car warning i18n keys", () => {
for (const locale of LOCALES) {
it(`exposes the required keys in locale '${locale}'`, () => {
const messages = readSelfWashSource(locale);
const selfWash = messages?.compat?.self_wash ?? {};
for (const key of REQUIRED_KEYS) {
const value = selfWash[key];
expect(value, `missing key self_wash.${key} in ${locale}`).toBeDefined();
expect(typeof value === "string" && value.trim() !== "", `empty value for self_wash.${key} in ${locale}`).toBe(
true
);
}
});
}
});
-74
View File
@@ -1,74 +0,0 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
import { createTestI18n } from "./helpers/mountWithApp.js";
const BMessageStub = {
name: "BMessage",
props: {
type: { type: String, default: "" },
title: { type: String, default: "" },
},
emits: ["close"],
template: `
<div
class="b-message-stub"
:data-type="type"
:data-title="title"
data-testid="red-car-warning"
>
<slot />
</div>
`,
};
const factory = (props = {}, messages = {}) => {
const i18n = createTestI18n({
en: {
self_wash: {
red_car_warning_title: "Red car on site",
red_car_warning_message: "Handle with extra care.",
red_car_warning_suggestion: "Use a gentler wash.",
...messages.en?.self_wash,
},
},
});
return mount(RedCarWarning, {
props,
global: {
plugins: [i18n],
stubs: {
BMessage: BMessageStub,
},
},
});
};
describe("RedCarWarning", () => {
it("renders the warning when isRedCar is true", () => {
const wrapper = factory({ isRedCar: true });
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="red-car-warning-message"]').text()).toBe("Handle with extra care.");
expect(wrapper.find('[data-testid="red-car-warning-suggestion"]').text()).toBe("Use a gentler wash.");
});
it("does not render the warning when isRedCar is false", () => {
const wrapper = factory({ isRedCar: false });
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
});
it("does not render by default (no prop passed)", () => {
const wrapper = factory();
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
});
it("forwards the close event from the b-message as a dismiss emit", () => {
const wrapper = factory({ isRedCar: true });
const bMessage = wrapper.findComponent(BMessageStub);
bMessage.vm.$emit("close");
expect(wrapper.emitted()).toHaveProperty("dismiss");
});
});
@@ -129,43 +129,4 @@ describe("selfServeDynamicImage", () => {
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER)).toBe(true);
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_START)).toBe(true);
});
// Snapshot lock for the full button-registry shape (TRU-19).
//
// The wash bay exposes 12 programs and 3 special buttons (reset / program
// picker / start). The dashboard renders one entry per program number
// from SELF_SERVE_TASK_BUTTON_OPTIONS, so the entire array must remain
// exactly 15 entries long: 1 reset + 1 program picker + 12 numeric
// programs (ids 0-11) + 1 start.
//
// If anyone adds, removes, or reorders entries without thinking about
// the operator UI and the api mapping, this snapshot will fail and
// force a deliberate update. The user-facing program names ("FF Uvs",
// "10min", "SF", etc.) live on the wash bay hardware and are NOT in
// this array — see TRU-19 and api SelfserveProgramRegistryContractTest.
it("locks the full SELF_SERVE_TASK_BUTTON_OPTIONS shape as a snapshot (15 entries, fixed order)", () => {
// Exact total length: 1 reset + 1 program picker + 12 programs + 1 start = 15
expect(SELF_SERVE_TASK_BUTTON_OPTIONS).toHaveLength(15);
// Full structural snapshot. The {id, name, description} triples must
// remain exactly as below. If you change one, change them all here
// deliberately.
expect(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => [entry.id, entry.name, entry.description])).toEqual([
[SELF_SERVE_TASK_BUTTON_RESET, "Reset", "Reset button"],
[SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER, "Program picker", "Program picker wheel"],
[0, "Program 1", "Machine button 0"],
[1, "Program 2", "Machine button 1"],
[2, "Program 3", "Machine button 2"],
[3, "Program 4", "Machine button 3"],
[4, "Program 5", "Machine button 4"],
[5, "Program 6", "Machine button 5"],
[6, "Program 7", "Machine button 6"],
[7, "Program 8", "Machine button 7"],
[8, "Program 9", "Machine button 8"],
[9, "Program 10", "Machine button 9"],
[10, "Program 11", "Machine button 10"],
[11, "Program 12", "Machine button 11"],
[SELF_SERVE_TASK_BUTTON_START, "Start", "Start button"],
]);
});
});
-87
View File
@@ -1,87 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT_PATH = path.resolve("scripts/smoke-test-sergii.sh");
const WORKFLOW_PATH = path.resolve(".github/workflows/deploy.yml");
const EXISTING_SMOKE_PATH = path.resolve("scripts/smoke-test.sh");
describe("Sergii Review Batch smoke test (TRU-96)", () => {
it("ships a Sergii-specific smoke test script with executable bits set", () => {
expect(fs.existsSync(SCRIPT_PATH)).toBe(true);
const stat = fs.statSync(SCRIPT_PATH);
// owner-execute bit (0o100) is what `chmod +x` sets; we don't care about
// the user the test runs as, just that the file is marked executable.
expect(stat.mode & 0o111).not.toBe(0);
});
it("covers every Sergii Review Batch project issue (TRU-68, TRU-72, TRU-75)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
// TRU-68: customer email flow
expect(contents).toContain("TRU-68");
expect(contents).toMatch(/customer email flow/i);
// TRU-72: pages 6-10 review
expect(contents).toContain("TRU-72");
expect(contents).toMatch(/pages 6-10/);
// TRU-75: UVS option
expect(contents).toContain("TRU-75");
expect(contents).toMatch(/UVS/);
});
it("probes the Sergii-touch surfaces that the Sergii batch changed (pages 6-10)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
// Pages 6-10 — Sergii's batch touched customer, product, order, booking, invoicing.
expect(contents).toMatch(/\/admin\/customer/);
expect(contents).toMatch(/\/admin\/product/);
expect(contents).toMatch(/\/admin\/order/);
expect(contents).toMatch(/\/admin\/booking/);
expect(contents).toMatch(/\/admin\/invoicing/);
});
it("probes the Sergii-touch customer email flow surfaces (TRU-68)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/\/api\/customer/);
expect(contents).toMatch(/\/kundeoprettelse/);
});
it("probes the Sergii-touch UVS self-serve surfaces (TRU-75)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/\/self-serve\/program/);
expect(contents).toMatch(/\/self-serve\/vehicle/);
});
it("is wired into the pleno-vue deploy workflow alongside the generic smoke test", () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, "utf8");
expect(workflow).toMatch(/smoke-test-sergii\.sh/);
expect(workflow).toMatch(/Sergii Review Batch smoke test \(TRU-96\)/);
// Generic smoke test still runs first.
expect(workflow).toMatch(/smoke-test\.sh "\$SMOKE_BASE_URL"/);
// Auto-rollback now also triggers on Sergii smoke failure.
expect(workflow).toMatch(/steps\.smoke\.outcome == 'failure' \|\| steps\.smoke_sergii\.outcome == 'failure'/);
// Slack status message distinguishes the two failure modes.
expect(workflow).toMatch(/Generic smoke FAILED/);
expect(workflow).toMatch(/Sergii Review Batch smoke FAILED/);
});
it("does not duplicate the generic smoke test (it complements, not replaces)", () => {
expect(fs.existsSync(EXISTING_SMOKE_PATH)).toBe(true);
const generic = fs.readFileSync(EXISTING_SMOKE_PATH, "utf8");
const sergii = fs.readFileSync(SCRIPT_PATH, "utf8");
// Generic covers login + /api/ping + /healthz; Sergii covers the Sergii
// Review Batch surfaces. They are intentionally different sets.
expect(generic).toMatch(/\/login/);
expect(sergii).toMatch(/\/admin\/customer/);
});
it("exits non-zero when any Sergii-touch surface fails, so auto-rollback can fire", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/FAIL=1/);
expect(contents).toMatch(/exit 1/);
expect(contents).toMatch(/Sergii Review Batch smoke test FAILED/);
});
it("defaults to https://staging.truckwash.io so it is safe to run unattended", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/SMOKE_BASE_URL:-\$\{1:-https:\/\/staging\.truckwash\.io\}/);
});
});
@@ -600,21 +600,6 @@ describe("Periode tab contract", () => {
expect(xlvaskUsagePaginationSource).toContain("buildUsagePaginationParams()");
});
it("widens the date window around a flagged wash start time to ±7 days (TRU-10)", () => {
// The original spec called for a [start_time - 7d, start_time + 7d] window
// so the highlighted wash is always visible even when it spans midnight
// or falls on the edge of the calendar day.
expect(periodViewSelfWashSource).toContain("FLAGGED_WASH_DATE_WINDOW_DAYS");
expect(periodViewSelfWashSource).toContain("FLAGGED_WASH_DATE_WINDOW_DAYS = 7");
expect(periodViewSelfWashSource).toContain("const shiftDateOnly");
expect(periodViewSelfWashSource).toContain(
"shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS)"
);
expect(periodViewSelfWashSource).toContain(
"shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS)"
);
});
it("loads Selvvask selector counts and progress from the selected period", () => {
expect(periodRightSource).toContain("normalizeXlvaskAutopilotSummary");
expect(periodRightSource).toContain("const selfWashCounts = ref(emptySelectorCounts())");
@@ -1,233 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const SCRIPT = "scripts/mobile/validate-app-store.mjs";
const ABS_SCRIPT = join(process.cwd(), SCRIPT);
const temporaryRoots = [];
afterEach(() => {
while (temporaryRoots.length > 0) {
rmSync(temporaryRoots.pop(), { recursive: true, force: true });
}
});
/**
* Build a minimal fixture repository root that satisfies every check in
* `validate-app-store.mjs` EXCEPT the iOS Xcode project bundle ID, which the
* caller controls via `xcodeBundleId`.
*/
function buildFixture({ xcodeBundleId, includeDebugId = true }) {
const root = mkdtempSync(join(tmpdir(), "tru-92-ios-guard-"));
temporaryRoots.push(root);
// ios/release.json
mkdirSync(join(root, "ios"), { recursive: true });
writeFileSync(
join(root, "ios/release.json"),
JSON.stringify(
{
marketingVersion: "1.1.0",
bundleId: "io.truckwash.app",
minimumIosVersion: "15.0",
},
null,
2
) + "\n"
);
// fastlane/metadata/da and copyright.txt
const files = {
name: "Truck Wash",
subtitle: "Vask af lastbil",
promotional_text: "Hurtig vask til lastbiler i hele Danmark.",
keywords: "truck,wash,dansk",
description: "Velkommen til Truck Wash. Scan QR, find afdeling, vask dit koretoj.",
release_notes: "Mindre forbedringer og fejlrettelser.",
};
mkdirSync(join(root, "fastlane/metadata/da"), { recursive: true });
for (const [name, value] of Object.entries(files)) {
const filename = name === "promotional_text" ? "promotional_text.txt" : `${name}.txt`;
writeFileSync(join(root, "fastlane/metadata/da", filename), value);
}
const urls = {
support_url: "https://truckwash.io/support",
privacy_url: "https://truckwash.io/privacy",
marketing_url: "https://truckwash.io",
};
for (const [name, value] of Object.entries(urls)) {
writeFileSync(join(root, "fastlane/metadata/da", `${name}.txt`), value);
}
writeFileSync(join(root, "fastlane/metadata/copyright.txt"), "2026 Truck Wash ApS");
// App icons (custom hashes so they do NOT match the known Capacitor defaults)
const iconDir = join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
const splashDir = join(root, "ios/App/App/Assets.xcassets/Splash.imageset");
mkdirSync(iconDir, { recursive: true });
mkdirSync(splashDir, { recursive: true });
// Build a tiny valid PNG with deterministic bytes that won't match the
// known Capacitor defaults. The validator only checks the SHA-256.
const png = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d,
0x49,
0x48,
0x44,
0x52, // IHDR length + tag
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01, // 1x1
0x08,
0x06,
0x00,
0x00,
0x00,
0x1f,
0x15,
0xc4,
0x89, // 8-bit RGBA
0x00,
0x00,
0x00,
0x0d,
0x49,
0x44,
0x41,
0x54, // IDAT length + tag
0x78,
0x9c,
0x62,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0d,
0x0a,
0x2d,
0xb4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4e,
0x44, // IEND length + tag
0xae,
0x42,
0x60,
0x82,
]);
writeFileSync(join(iconDir, "AppIcon-512@2x.png"), png);
writeFileSync(join(splashDir, "splash-2732x2732.png"), png);
// Xcode project with a controllable bundle ID
mkdirSync(join(root, "ios/App/App.xcodeproj"), { recursive: true });
const ids = [`PRODUCT_BUNDLE_IDENTIFIER = ${xcodeBundleId};`];
if (includeDebugId) ids.push("PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app.debug;");
const xcode = [
"// !$*UTF8*$!",
"{ archive = { isa = PBXProject; buildConfigurationList = LIST; }; }",
"begin XCBuildConfiguration section",
"buildSettings = {",
...ids.map((line) => ` ${line}`),
" INFOPLIST_FILE = ios/App/App/Info.plist;",
" PRODUCT_NAME = TruckWash;",
"};",
"end XCBuildConfiguration section",
].join("\n");
writeFileSync(join(root, "ios/App/App.xcodeproj/project.pbxproj"), xcode);
return root;
}
function runValidator(fixtureRoot) {
return spawnSync("node", [ABS_SCRIPT], {
cwd: fixtureRoot,
encoding: "utf8",
timeout: 30_000,
});
}
describe("TRU-92 iOS bundle ID guard", () => {
it("passes when the iOS Xcode project uses io.truckwash.app and a debug variant", () => {
const fixtureRoot = buildFixture({ xcodeBundleId: "io.truckwash.app" });
const result = runValidator(fixtureRoot);
expect(result.status).toBe(0);
expect(result.stdout).toContain("App Store metadata is valid");
expect(result.stderr).not.toContain("PRODUCT_BUNDLE_IDENTIFIER");
});
it("fails when the iOS Xcode project bundle ID has been clobbered to the TWA appId", () => {
// Simulate what `npx cap sync ios` would do: copy the Android TWA appId
// from capacitor.config.ts into the iOS Xcode project, which would break
// Mads's iPhone install.
const fixtureRoot = buildFixture({ xcodeBundleId: "io.truckwash.twa" });
const result = runValidator(fixtureRoot);
expect(result.status).toBe(1);
expect(result.stderr).toContain("PRODUCT_BUNDLE_IDENTIFIER");
expect(result.stderr).toContain("io.truckwash.app");
expect(result.stderr).toContain("io.truckwash.twa");
// The error message lists the IDs found in the Xcode project.
expect(result.stderr).toContain("io.truckwash.app.debug");
// The fix is run from a tmpdir, so the relative path differs — accept any.
expect(result.stderr).toMatch(/project\.pbxproj/u);
});
it("fails when an unrelated bundle ID sneaks into the iOS project", () => {
const fixtureRoot = buildFixture({ xcodeBundleId: "com.example.wrong" });
const result = runValidator(fixtureRoot);
expect(result.status).toBe(1);
expect(result.stderr).toContain("com.example.wrong");
expect(result.stderr).toContain("unexpected PRODUCT_BUNDLE_IDENTIFIER");
});
it("still passes for a debug-only project (no release target) only if the only ID is the debug variant", () => {
const fixtureRoot = buildFixture({
xcodeBundleId: "io.truckwash.app.debug",
includeDebugId: false,
});
const result = runValidator(fixtureRoot);
// The release target is missing in this scenario, which is a real failure
// mode that would also break the production iOS release pipeline.
expect(result.status).toBe(1);
expect(result.stderr).toContain("io.truckwash.app");
});
});
// Sanity check: ensure the script under test actually exists in the working
// tree so this test does not silently pass when the validator is moved.
describe("validate-app-store.mjs presence", () => {
it("is wired up to a real script file", () => {
const repoRoot = process.cwd();
const scriptPath = join(repoRoot, SCRIPT);
expect(existsSync(scriptPath)).toBe(true);
// Touch the unused import to keep it (used to assert the fixture path is
// meaningful and the relative() helper resolves).
expect(relative(repoRoot, scriptPath)).toBe(SCRIPT);
});
});
@@ -15,13 +15,8 @@ describe("xlvask usage pagination department selector propagation", () => {
it("applies the HallId filter when the departmentId prop is provided", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
// `effectiveDepartmentId` is now a computed (so the value is `.value`)
// and is wired into a watcher that re-applies the HallId filter
// whenever the department changes. The initial setup also seeds
// the filter from the computed.
expect(source).toMatch(/effectiveDepartmentId\.value\s*>\s*0/);
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\.value\s*,\s*false\s*\)/);
expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
expect(source).toMatch(/effectiveDepartmentId\s*>\s*0/);
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\s*,\s*false\s*\)/);
});
it("falls back to the departmentId route param when the prop is not provided", () => {
@@ -34,13 +29,10 @@ describe("xlvask usage pagination department selector propagation", () => {
it("does not apply the HallId filter when no departmentId is provided", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
// `effectiveDepartmentId` is a computed (not a plain const) so the
// value is `.value`, and it is read through `routeDepartmentId.value`
// for the route-param fallback.
expect(source).toMatch(
/const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId\s*>\s*0[\s\S]*?routeDepartmentId\.value\s*>\s*0[\s\S]*?:\s*0\s*\)/
expect(source).toContain(
"const effectiveDepartmentId =\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0\n ? routeDepartmentId\n : 0;"
);
expect(source).toMatch(/if\s*\(effectiveDepartmentId\.value\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
expect(source).toMatch(/if\s*\(effectiveDepartmentId\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
});
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
@@ -1,51 +0,0 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("XLVaskUsagePagination department (HallId) propagation", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
it("reacts to department changes via a computed effectiveDepartmentId", () => {
// The department must be derived reactively from the prop and the
// route param, not captured once at setup time.
expect(source).toMatch(
/const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId[\s\S]*?routeDepartmentId\.value[\s\S]*?\)\s*\)/
);
});
it("watches the effective department and re-applies the HallId filter", () => {
// The component must watch the computed effective department so
// changing the department selector re-issues the query with the
// new department in the filter.
expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*nextDepartmentId\s*,\s*false\s*\)/);
});
it("re-issues the usage query when the department changes", () => {
// The watch handler must trigger both loadList and loadSummary so
// the visible orders and the summary cards both reflect the new
// department.
const watchBlockMatch = source.match(/watch\(\s*effectiveDepartmentId\s*,[\s\S]*?\n\)\s*;/);
expect(watchBlockMatch).not.toBeNull();
const watchBlock = watchBlockMatch?.[0] ?? "";
expect(watchBlock).toMatch(/loadList\s*\(\s*\)/);
expect(watchBlock).toMatch(/loadSummary\s*\(\s*\)/);
});
it("clears the HallId filter when the department is unset", () => {
// When the new department is 0 / unset, the filter must be cleared
// (setFilter with '*' removes the key) so the query is not bound
// to a stale department.
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*["']\*["']\s*,\s*false\s*\)/);
});
it("includes the active department in the summary query params", () => {
// The summary endpoint must also be re-issued with the new
// department; otherwise the summary cards show stale counts.
expect(source).toMatch(/HallId:\s*effectiveDepartmentId\.value/);
});
});