From b89a946d8d7a68185b896bb600e6a14cd2399e21 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 20:56:21 +0800 Subject: [PATCH 001/133] test: make the engine's own test suite bootable The dummy app never ran: ember-classic-decorator (unused) crashes on Ember 5, lazy loading kept the engine's modules out of the test bundle, and the host services, config and intl data the panels need were missing. Mirrors what fleetops already does: eager loading when `ember test` runs from this package, an inert stand-in for the console's extensions module, the API config the core adapter reads at module scope, and the legacy built-in components the engine's templates still use. The dummy intl service ignores missing locale data, since headless Chrome has no number formats for every bundled locale, and the order details and network controller tests stub the host router, fetch and app cache the way the widget tests already do. --- index.js | 8 +- package.json | 4 +- pnpm-lock.yaml | 84 +++++++++++-------- tests/dummy/app/services/intl.js | 18 ++++ tests/dummy/config/environment.js | 14 ++++ .../components/shared-identity-test.js | 4 +- .../storefront/order/details-test.js | 53 ++++++++++-- .../components/widget/customers-test.js | 8 ++ tests/test-helper.js | 11 +++ .../unit/controllers/customers/index-test.js | 2 + .../networks/index/network/customers-test.js | 8 ++ .../networks/index/network/orders-test.js | 8 ++ tests/unit/controllers/orders/index-test.js | 2 + 13 files changed, 174 insertions(+), 50 deletions(-) create mode 100644 tests/dummy/app/services/intl.js diff --git a/index.js b/index.js index 005d7714..10a66128 100644 --- a/index.js +++ b/index.js @@ -8,8 +8,14 @@ const path = require('path'); module.exports = buildEngine({ name, + // Lazy loading keeps the engine out of the host bundle, but it also keeps the + // engine's modules out of the dummy app that `ember test` builds, so every test + // importing `@fleetbase/storefront-engine/*` fails to load. Eager loading is + // scoped to `ember test` run from this package, so host apps consuming the + // engine, including their own test builds, keep lazy loading. Addon index + // files are evaluated before ember-cli assigns EMBER_ENV, hence the argv check. lazyLoading: { - enabled: true, + enabled: !(process.argv.includes('test') && process.cwd() === __dirname), }, isDevelopingAddon() { diff --git a/package.json b/package.json index 344a57db..33417fed 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.25", "@fleetbase/ember-ui": "^0.4.5", - "@fleetbase/fleetops-data": "^0.2.3", + "@fleetbase/fleetops-data": "^0.2.2", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", @@ -61,6 +61,7 @@ "devDependencies": { "@babel/eslint-parser": "^7.22.15", "@babel/plugin-proposal-decorators": "^7.23.2", + "@ember/legacy-built-in-components": "^0.4.2", "@ember/optional-features": "^2.0.0", "@ember/test-helpers": "^3.2.0", "@embroider/test-setup": "^3.0.2", @@ -71,7 +72,6 @@ "broccoli-funnel": "^3.0.8", "broccoli-merge-trees": "^4.2.0", "concurrently": "^8.2.2", - "ember-classic-decorator": "^3.0.0", "ember-cli": "~5.4.1", "ember-cli-clean-css": "^3.0.0", "ember-cli-dependency-checker": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 676fd872..091b4870 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.4.5 version: 0.4.5(fd4e3593913b6af1971bbb09f229d016) '@fleetbase/fleetops-data': - specifier: ^0.1.37 - version: 0.1.37(d08c013ce78c1181b04d076f9e3ef4de) + specifier: ^0.2.2 + version: 0.2.2(d08c013ce78c1181b04d076f9e3ef4de) '@fortawesome/ember-fontawesome': specifier: ^2.0.0 version: 2.0.0(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(rollup@2.80.0)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) @@ -57,6 +57,9 @@ importers: '@babel/plugin-proposal-decorators': specifier: ^7.23.2 version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@ember/legacy-built-in-components': + specifier: ^0.4.2 + version: 0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0(supports-color@8.1.1) @@ -87,9 +90,6 @@ importers: concurrently: specifier: ^8.2.2 version: 8.2.2 - ember-classic-decorator: - specifier: ^3.0.0 - version: 3.0.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) ember-cli: specifier: ~5.4.1 version: 5.4.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@25.9.1)(debug@4.4.3(supports-color@8.1.1))(handlebars@4.7.9)(supports-color@8.1.1)(underscore@1.13.8) @@ -116,7 +116,7 @@ importers: version: 4.12.8(@babel/core@7.29.7(supports-color@8.1.1))(@ember/string@3.1.1(supports-color@8.1.1))(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) ember-engines: specifier: ^0.9.0 - version: 0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) + version: 0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(@ember/legacy-built-in-components@0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) @@ -1147,6 +1147,12 @@ packages: '@ember/edition-utils@1.2.0': resolution: {integrity: sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog==} + '@ember/legacy-built-in-components@0.4.2': + resolution: {integrity: sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==} + engines: {node: 12.* || 14.* || >= 16} + peerDependencies: + ember-source: '*' + '@ember/optional-features@2.3.0': resolution: {integrity: sha512-+M8CkPledQEaDbfIlwlq6Phgpm5jdT3a6WVDJk7b/zadw5xAJkuQKVK7DgR0SFgHGiWlyn6a8AU5p2mCA706RA==} engines: {node: 10.* || 12.* || >= 14} @@ -1283,14 +1289,14 @@ packages: resolution: {integrity: sha512-C/+OvMpH5fCKYY5v+CteR6Tx6IAI33nfvOrmo7mFGDnyKDsjplsVVgWpsecLG94fJvpuVVI+CFvaiH50GUM3Mg==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.37': - resolution: {integrity: sha512-VGVWaGlwQAqHWA9/A80jKAjoDkAM7U9Ve1UVmKkCTlolqUd5/k/Z66rVC0iFUijj8qzkMCdahdh3mBPQ6x71WA==} - engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.40': resolution: {integrity: sha512-bp4tWaS2cpnitjtmH9hm3T7milwSqgt4j3T/KA8cvsFpbg7p303ADb5CNml5RsribgdBcRYtMsLEFYeYnU9dpg==} engines: {node: '>= 18'} + '@fleetbase/fleetops-data@0.2.2': + resolution: {integrity: sha512-HDgcGpJAk8q+sSmV070nCh02o4S0kuFCvwrf+Ltb+IrUkQ2RhDL/w8gdrqM65HrgbgWxQnslIeqzKfF6pV4/DA==} + engines: {node: '>= 18'} + '@fleetbase/intl-lint@0.0.1': resolution: {integrity: sha512-LkjxJr15hSiGmqh3JwixcpjmkhXNieNAEgQUVv1Duo50jTr/D5WXEyOaeI8wuVnVFhT+FS/DMqm403DgSKlsEg==} hasBin: true @@ -3140,6 +3146,7 @@ packages: concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} concurrently@8.2.2: resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} @@ -3750,10 +3757,6 @@ packages: ember-resolver: '>= 8.0.0' ember-source: ^3.28.0 || ^4.0.0 || >=5.0.0 - ember-classic-decorator@3.0.1: - resolution: {integrity: sha512-IoocHPX1cY93Sma7VeDGbF0M1+TkBWDJLus+RD01902eD+KHEL2+diwM+iayCyO+1/xaUzdzNGh35J+i+TCxoA==} - engines: {node: 12.* || 14.* || >= 16} - ember-cli-babel-plugin-helpers@1.1.1: resolution: {integrity: sha512-sKvOiPNHr5F/60NLd7SFzMpYPte/nnGkq/tMIfXejfKHIhaiIkYFqX8Z9UFTKWLLn+V7NOaby6niNPZUdvKCRw==} engines: {node: 6.* || 8.* || >= 10.*} @@ -9866,6 +9869,18 @@ snapshots: '@ember/edition-utils@1.2.0': {} + '@ember/legacy-built-in-components@0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1)': + dependencies: + '@embroider/macros': 1.20.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + ember-cli-babel: 7.26.11(supports-color@8.1.1) + ember-cli-htmlbars: 5.7.2(supports-color@8.1.1) + ember-cli-typescript: 4.2.1(supports-color@8.1.1) + ember-source: 5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + '@ember/optional-features@2.3.0(supports-color@8.1.1)': dependencies: chalk: 4.1.2 @@ -9953,7 +9968,7 @@ snapshots: find-up: 5.0.0 lodash: 4.18.1 resolve: 1.22.12 - semver: 7.8.1 + semver: 7.8.5 transitivePeerDependencies: - '@babel/core' - supports-color @@ -10001,10 +10016,10 @@ snapshots: js-string-escape: 1.0.1 lodash: 4.18.1 minimatch: 3.1.5 - pkg-entry-points: 1.1.1 + pkg-entry-points: 1.1.2 resolve-package-path: 4.0.3 resolve.exports: 2.0.3 - semver: 7.8.1 + semver: 7.8.5 typescript-memoize: 1.1.1 transitivePeerDependencies: - supports-color @@ -10249,6 +10264,7 @@ snapshots: - ember-cli-mirage - ember-resolver - ember-source + - encoding - eslint - miragejs - postcss @@ -10263,7 +10279,7 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.1.37(d08c013ce78c1181b04d076f9e3ef4de)': + '@fleetbase/fleetops-data@0.1.40(d08c013ce78c1181b04d076f9e3ef4de)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@fleetbase/ember-core': 0.3.25(d08c013ce78c1181b04d076f9e3ef4de) @@ -10284,10 +10300,10 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/fleetops-data@0.1.40(d08c013ce78c1181b04d076f9e3ef4de)': + '@fleetbase/fleetops-data@0.2.2(d08c013ce78c1181b04d076f9e3ef4de)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) - '@fleetbase/ember-core': 0.3.24(d08c013ce78c1181b04d076f9e3ef4de) + '@fleetbase/ember-core': 0.3.25(d08c013ce78c1181b04d076f9e3ef4de) date-fns: 2.30.0 ember-cli-babel: 8.3.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) ember-cli-htmlbars: 6.3.0(supports-color@8.1.1) @@ -10298,6 +10314,7 @@ snapshots: - bufferutil - ember-resolver - ember-source + - encoding - eslint - supports-color - typescript @@ -11055,7 +11072,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 13.0.6 '@types/http-errors@2.0.5': {} @@ -11080,7 +11097,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 7.4.9 + minimatch: 10.2.5 '@types/minimist@1.2.5': {} @@ -11586,7 +11603,7 @@ snapshots: babel-generator: 6.26.1 babel-helpers: 6.24.1(supports-color@8.1.1) babel-messages: 6.23.0 - babel-register: 6.26.0 + babel-register: 6.26.0(supports-color@8.1.1) babel-runtime: 6.26.0 babel-template: 6.26.0(supports-color@8.1.1) babel-traverse: 6.26.0(supports-color@8.1.1) @@ -11742,7 +11759,7 @@ snapshots: babel-plugin-syntax-dynamic-import@6.18.0: {} - babel-register@6.26.0: + babel-register@6.26.0(supports-color@8.1.1): dependencies: babel-core: 6.26.3(supports-color@8.1.1) babel-runtime: 6.26.0 @@ -11751,6 +11768,8 @@ snapshots: lodash: 4.18.1 mkdirp: 0.5.6 source-map-support: 0.4.18 + transitivePeerDependencies: + - supports-color babel-runtime@6.26.0: dependencies: @@ -13512,17 +13531,6 @@ snapshots: - '@babel/core' - supports-color - ember-classic-decorator@3.0.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): - dependencies: - '@embroider/macros': 1.20.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) - babel-plugin-filter-imports: 4.0.0 - ember-cli-babel: 7.26.11(supports-color@8.1.1) - ember-cli-htmlbars: 6.3.0(supports-color@8.1.1) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - ember-cli-babel-plugin-helpers@1.1.1: {} ember-cli-babel@7.26.11(supports-color@8.1.1): @@ -13655,7 +13663,7 @@ snapshots: hash-for-dep: 1.5.2(supports-color@8.1.1) heimdalljs-logger: 0.1.10(supports-color@8.1.1) json-stable-stringify: 1.3.0 - semver: 7.8.1 + semver: 7.8.5 silent-error: 1.1.1(supports-color@8.1.1) strip-bom: 4.0.0 walk-sync: 2.2.0 @@ -14173,7 +14181,7 @@ snapshots: transitivePeerDependencies: - supports-color - ember-engines@0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1): + ember-engines@0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(@ember/legacy-built-in-components@0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1): dependencies: '@embroider/macros': 1.20.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) amd-name-resolver: 1.3.1 @@ -14193,6 +14201,8 @@ snapshots: ember-cli-version-checker: 5.1.2(supports-color@8.1.1) ember-source: 5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) lodash: 4.18.1 + optionalDependencies: + '@ember/legacy-built-in-components': 0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) transitivePeerDependencies: - '@babel/core' - '@glint/template' diff --git a/tests/dummy/app/services/intl.js b/tests/dummy/app/services/intl.js new file mode 100644 index 00000000..90cd3308 --- /dev/null +++ b/tests/dummy/app/services/intl.js @@ -0,0 +1,18 @@ +import IntlService from 'ember-intl/services/intl'; + +/** + * ember-intl builds a formatter for every bundled locale when the service is + * created. Headless Chrome ships no number-format data for some of them + * (Mongolian among them), which ember-intl's default handler turns into a thrown + * error inside whichever test first touches intl. Missing locale data and + * missing translations are not what any test here checks, so both are ignored. + */ +export default class DummyIntlService extends IntlService { + onIntlError(error) { + if (error?.code === 'MISSING_DATA' || error?.code === 'MISSING_TRANSLATION') { + return; + } + + super.onIntlError(error); + } +} diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index 61f3a09d..12eb0fdb 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -14,6 +14,20 @@ module.exports = function (environment) { }, }, + // `@fleetbase/ember-core`'s application adapter reads `config.API.host` at + + // module scope, so the dummy application must declare it before any + + // adapter module can even be evaluated. The values are deliberately + + // obviously-fake: nothing in the suite performs a real request. + + API: { + host: 'https://api.fleetbase.test', + + namespace: 'int/v1', + }, + APP: { // Here you can pass flags/options to your application instance // when it is created diff --git a/tests/integration/components/shared-identity-test.js b/tests/integration/components/shared-identity-test.js index 00bf135c..aed5eba7 100644 --- a/tests/integration/components/shared-identity-test.js +++ b/tests/integration/components/shared-identity-test.js @@ -17,7 +17,7 @@ module('Integration | Component | shared identity components', function (hooks) }); test('a driver identity cell renders the name, status dot and vehicle badge from the shared descriptor', async function (assert) { - this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', vehicle_name: 'Truck 10' } }); + this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', online: true, vehicle_name: 'Truck 10' } }); this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', popover: false }); await render(hbs``); @@ -52,7 +52,7 @@ module('Integration | Component | shared identity components', function (hooks) `); assert.dom('[data-test-customer] [data-test-resource-pill-title]').hasText('Ava Chen'); - assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Customer'); + assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Contact', 'the polymorphic type reads as a person would say it'); assert.dom('[data-test-driver] [data-test-resource-pill-title]').hasText('Ada Driver'); assert.dom('[data-test-driver] [data-test-resource-pill-subtitle]').hasText('+15550100'); assert.dom('[data-test-place] [data-test-resource-pill-title]').hasText('Depot'); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index 981b8944..185ebc4a 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -1,13 +1,49 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render, findAll } from '@ember/test-helpers'; +import Service from '@ember/service'; import { hbs } from 'ember-cli-htmlbars'; import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/order/details', function (hooks) { setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); hooks.beforeEach(function () { + // The activity, route and tracking panels fetch on insert and the dummy app has + // no API. A request that never answers leaves each task pending, and ember-concurrency + // cancels it with its component, so nothing writes to a torn-down panel. + this.owner.register( + 'service:fetch', + class extends Service { + get() { + return new Promise(() => {}); + } + + post() { + return new Promise(() => {}); + } + } + ); + // The app cache keeps a proxy that outlives a test's owner; the activity panel reads + // it while initialising, so give it an inert cache like the widget tests do. + this.owner.register( + 'service:app-cache', + class extends Service { + get() { + return undefined; + } + + set() {} + + setEmberData() {} + + getEmberData() { + return []; + } + } + ); initialize(this.owner); }); @@ -55,13 +91,14 @@ module('Integration | Component | storefront/order/details', function (hooks) { await render(hbs``); - assert.dom('.next-content-panel-title-container').hasTextContaining('Activity'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Store'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Order'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Details'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Customer Insights'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Route'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Metadata'); + const titles = findAll('.next-content-panel-title-container').map((element) => element.textContent.trim()); + + for (const title of ['Activity', 'Store', 'Order', 'Details', 'Customer Insights', 'Route', 'Metadata']) { + assert.ok( + titles.some((text) => text.includes(title)), + `a panel titled ${title} renders` + ); + } assert.dom('.storefront-order-person__name').hasTextContaining('Tasty Store'); assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index 4aa03639..9a9f20b2 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -26,6 +26,14 @@ class FetchStubService extends Service { } class IntlStubService extends Service { + exists() { + return false; + } + + onLocaleChanged() { + return () => {}; + } + t(key) { if (key === 'storefront.component.widget.customers.widget-title') { return 'Recent Customers'; diff --git a/tests/test-helper.js b/tests/test-helper.js index 4efd6e58..d355f8d0 100644 --- a/tests/test-helper.js +++ b/tests/test-helper.js @@ -5,6 +5,17 @@ import { setApplication } from '@ember/test-helpers'; import { setup } from 'qunit-dom'; import { start } from 'ember-qunit'; +// The universe extension manager imports a module the console host generates at build +// time. The engine's dummy app has no host, so provide an inert stand-in before boot. +if (typeof window.define === 'function' && !window.requirejs?.entries?.['@fleetbase/console/extensions']) { + window.define('@fleetbase/console/extensions', ['exports'], function (exports) { + exports.getExtensionLoader = function () { + return null; + }; + exports.default = {}; + }); +} + setApplication(Application.create(config.APP)); setup(QUnit.assert); diff --git a/tests/unit/controllers/customers/index-test.js b/tests/unit/controllers/customers/index-test.js index d8189724..d29291a8 100644 --- a/tests/unit/controllers/customers/index-test.js +++ b/tests/unit/controllers/customers/index-test.js @@ -1,8 +1,10 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; module('Unit | Controller | customers/index', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); test('it exists', function (assert) { let controller = this.owner.lookup('controller:customers/index'); diff --git a/tests/unit/controllers/networks/index/network/customers-test.js b/tests/unit/controllers/networks/index/network/customers-test.js index bd90c9fb..b86fd691 100644 --- a/tests/unit/controllers/networks/index/network/customers-test.js +++ b/tests/unit/controllers/networks/index/network/customers-test.js @@ -1,8 +1,16 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import Service from '@ember/service'; module('Unit | Controller | networks/index/network/customers', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + // The engine's dummy app has no console host to provide the host router. + this.owner.register('service:hostRouter', class extends Service {}); + }); test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/customers'); diff --git a/tests/unit/controllers/networks/index/network/orders-test.js b/tests/unit/controllers/networks/index/network/orders-test.js index 141a91cc..a6b26e5d 100644 --- a/tests/unit/controllers/networks/index/network/orders-test.js +++ b/tests/unit/controllers/networks/index/network/orders-test.js @@ -1,8 +1,16 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import Service from '@ember/service'; module('Unit | Controller | networks/index/network/orders', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + // The engine's dummy app has no console host to provide the host router. + this.owner.register('service:hostRouter', class extends Service {}); + }); test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/orders'); diff --git a/tests/unit/controllers/orders/index-test.js b/tests/unit/controllers/orders/index-test.js index 6c9ca5d4..5a2748a1 100644 --- a/tests/unit/controllers/orders/index-test.js +++ b/tests/unit/controllers/orders/index-test.js @@ -1,9 +1,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; import Service from '@ember/service'; module('Unit | Controller | orders/index', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); test('it exists', function (assert) { let controller = this.owner.lookup('controller:orders/index'); From 3c6ad104cdd805ad89dd676b382b540a1df689bb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 12:03:38 +0800 Subject: [PATCH 002/133] feat(food-trucks): networks show their member stores' trucks; a truck is only found by its own storefront --- .../Controllers/v1/FoodTruckController.php | 56 ++++++++++++++----- server/src/Http/Resources/FoodTruck.php | 8 +++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index be35a803..4f07070b 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Resources\FoodTruck as FoodTruckResource; use Fleetbase\Storefront\Models\FoodTruck; +use Fleetbase\Storefront\Models\Network; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -17,25 +18,47 @@ class FoodTruckController extends Controller */ public function query(Request $request) { - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); - $results = []; + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); + $storeIds = static::storeUuidsForStorefront(); + if (empty($storeIds)) { + return FoodTruckResource::collection([]); + } + + $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset, $storeIds) { + $query->whereIn('store_uuid', $storeIds)->with(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + + if ($limit) { + $query->limit($limit); + } + + if ($offset) { + $query->offset($offset); + } + }); + return FoodTruckResource::collection($results); + } + + /** + * The stores whose food trucks this storefront shows: a store's own, or in a network + * every member store's, so a network can run trucks alongside its stores. + * + * @return array + */ + protected static function storeUuidsForStorefront(): array + { if (session('storefront_store')) { - $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset) { - $query->where('store_uuid', session('storefront_store')); + return [session('storefront_store')]; + } - if ($limit) { - $query->limit($limit); - } + if (session('storefront_network')) { + $network = Network::where('uuid', session('storefront_network'))->first(); - if ($offset) { - $query->offset($offset); - } - }); + return $network ? $network->stores()->pluck('stores.uuid')->all() : []; } - return FoodTruckResource::collection($results); + return []; } /** @@ -52,6 +75,13 @@ public function find($id) return response()->error('Food Truck resource not found.'); } + // Only a truck of this storefront (its own, or a network member store's). + if (!in_array($foodTruck->store_uuid, static::storeUuidsForStorefront(), true)) { + return response()->error('Food Truck resource not found.', 404); + } + + $foodTruck->loadMissing(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + // response the product resource return new FoodTruckResource($foodTruck); } diff --git a/server/src/Http/Resources/FoodTruck.php b/server/src/Http/Resources/FoodTruck.php index 942e3624..028be3e6 100644 --- a/server/src/Http/Resources/FoodTruck.php +++ b/server/src/Http/Resources/FoodTruck.php @@ -36,6 +36,14 @@ public function toArray($request) 'location' => $this->vehicle ? $this->vehicle->location : null, 'online' => $this->vehicle ? $this->vehicle->online : false, 'status' => $this->status, + // The store the truck belongs to, so a network can name it and order from it. + 'store' => $this->whenLoaded('store', fn () => $this->store ? [ + 'id' => $this->store->public_id, + 'name' => $this->store->name, + 'logo_url' => $this->store->logo_url, + 'currency' => $this->store->currency, + 'options' => $this->store->options, + ] : null), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; From e60a7066048f28aa5f12a8c3d08a6511854afb81 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 12:30:24 +0800 Subject: [PATCH 003/133] feat(cards): catalogs, trucks and networks render on the shared resource card Catalog, truck and network index pages use Layout::Resource::CardsGrid with one card each on Layout::Resource::Card, following the Fleet-Ops anatomy: identity and subtitle in the header, media or facts in the body, status, icon actions and meta in the footer, with registry slots at each end. A catalog card shows its categories with product counts, its hours as a seven-day strip (the new Storefront::HoursStrip) and its product total; a draft is dashed. A truck card is a Vehicle::Pill with the online dot, the vehicle photo, where it serves, and a presence badge that says serving, offline, inactive, or online with no catalog. A network card shows the logo on its backdrop, the online state, the currency and member count. "Food trucks" reads "Trucks" in the UI; the food-truck model, routes and permissions keep their names. The catalogs and trucks pages gain the search the header already offered. --- addon/components/modals/create-food-truck.hbs | 12 +- addon/components/storefront/catalog/card.hbs | 53 +++++ addon/components/storefront/catalog/card.js | 48 ++++ addon/components/storefront/hours-strip.hbs | 8 + addon/components/storefront/hours-strip.js | 34 +++ addon/components/storefront/network/card.hbs | 44 ++++ addon/components/storefront/network/card.js | 29 +++ addon/components/storefront/truck/card.hbs | 49 ++++ addon/components/storefront/truck/card.js | 74 ++++++ addon/controllers/application.js | 2 +- addon/controllers/catalogs/index.js | 17 +- addon/controllers/food-trucks/index.js | 27 ++- addon/models/food-truck.js | 2 + addon/styles/storefront-engine.css | 225 ++++++++++++++++++ addon/templates/catalogs/index.hbs | 57 ++--- addon/templates/food-trucks/index.hbs | 59 ++--- addon/templates/networks/index.hbs | 91 ++----- app/components/storefront/catalog/card.js | 1 + app/components/storefront/hours-strip.js | 1 + app/components/storefront/network/card.js | 1 + app/components/storefront/truck/card.js | 1 + .../storefront/catalog/card-test.js | 53 +++++ .../components/storefront/hours-strip-test.js | 33 +++ .../storefront/network/card-test.js | 48 ++++ .../components/storefront/truck/card-test.js | 59 +++++ translations/en-us.yaml | 32 ++- 26 files changed, 888 insertions(+), 172 deletions(-) create mode 100644 addon/components/storefront/catalog/card.hbs create mode 100644 addon/components/storefront/catalog/card.js create mode 100644 addon/components/storefront/hours-strip.hbs create mode 100644 addon/components/storefront/hours-strip.js create mode 100644 addon/components/storefront/network/card.hbs create mode 100644 addon/components/storefront/network/card.js create mode 100644 addon/components/storefront/truck/card.hbs create mode 100644 addon/components/storefront/truck/card.js create mode 100644 app/components/storefront/catalog/card.js create mode 100644 app/components/storefront/hours-strip.js create mode 100644 app/components/storefront/network/card.js create mode 100644 app/components/storefront/truck/card.js create mode 100644 tests/integration/components/storefront/catalog/card-test.js create mode 100644 tests/integration/components/storefront/hours-strip-test.js create mode 100644 tests/integration/components/storefront/network/card-test.js create mode 100644 tests/integration/components/storefront/truck/card-test.js diff --git a/addon/components/modals/create-food-truck.hbs b/addon/components/modals/create-food-truck.hbs index 3332b6b6..cfc3e543 100644 --- a/addon/components/modals/create-food-truck.hbs +++ b/addon/components/modals/create-food-truck.hbs @@ -1,11 +1,11 @@ + {{/each}} + {{#if @fields.custom.length}} +
{{t "storefront.products.form.translations.custom-fields"}}
+ {{#each @fields.custom as |field|}} +
+ {{field.key}} + + {{#unless @disabled}} +
+ {{/each}} + {{/if}} + {{#unless @disabled}} +
+ +
+ {{/unless}} +

{{t "storefront.products.form.translations.help"}}

+ + {{else}} +

{{t "storefront.products.form.translations.none"}}

+ {{/if}} + diff --git a/addon/components/storefront/product/form/variants.hbs b/addon/components/storefront/product/form/variants.hbs index 7dd1ab2e..b66fbc34 100644 --- a/addon/components/storefront/product/form/variants.hbs +++ b/addon/components/storefront/product/form/variants.hbs @@ -1,31 +1,58 @@ -{{! Each variant is a compact block: its name and rules, then one row per option. }} -
+{{! Each variant is a group: its name and rules, then its options as a small table, Add option under it. }} +
{{#each @product.variants as |variant|}} -
-
-
+
+
+
{{n-a variant.name}} - {{#if variant.is_required}}{{/if}} - {{#if variant.is_multiselect}}{{/if}} + + {{if variant.is_required (t "storefront.products.form.variants.required") (t "storefront.products.form.variants.optional")}} + · + {{if variant.is_multiselect (t "storefront.products.form.variants.multiple") (t "storefront.products.form.variants.pick-one")}} + · + {{t "storefront.products.form.variants.options-count" count=variant.options.length}} +
-
-
- {{#each variant.options as |variantOption index|}} -
- - - -
+ {{#if variant.options.length}} + + + + + + + + + + + {{#each variant.options as |variantOption index|}} + + + + + + + {{/each}} + +
{{t "storefront.products.form.variants.option"}}{{t "storefront.common.description"}}{{t "storefront.products.form.variants.extra-cost"}}{{t "common.remove"}}
{{else}} -
{{t "storefront.products.form.fields.no-options"}}
- {{/each}} +
{{t "storefront.products.form.fields.no-options"}}
+ {{/if}} + {{#if (can "storefront create product-variant-option")}} + + {{/if}}
{{else}} -
{{t "storefront.products.form.variants.none"}}
+
+ +
{{t "storefront.products.form.variants.none"}}
+
{{/each}}
diff --git a/addon/components/storefront/product/form/videos.hbs b/addon/components/storefront/product/form/videos.hbs index c9bfcc96..24b7f615 100644 --- a/addon/components/storefront/product/form/videos.hbs +++ b/addon/components/storefront/product/form/videos.hbs @@ -1,5 +1,40 @@ -
- - - +{{! YouTube links shown in the product gallery: one row per link, its thumbnail once the link is valid. }} +
+
+ {{t "storefront.products.form.fields.video-urls"}} + {{#unless @disabled}} + + {{/unless}} +
+ {{#each @rows as |row|}} +
+ + {{#if row.thumbnail}} + + {{else}} + + {{/if}} + + + {{#unless @disabled}} +
+ {{#if (and row.url (not row.isValid))}} +

{{t "storefront.products.form.videos.invalid"}}

+ {{/if}} + {{else}} +

{{t "storefront.products.form.fields.video-urls-help"}}

+ {{/each}}
diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index e57d401c..4271ddf8 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -9807,3 +9807,456 @@ body[data-theme='dark'] .storefront-table--rows tbody tr.is-clickable:hover td { flex-basis: 7.5rem; height: 7.5rem; } + +/* Schedule manager: the week as compact rows (day, slots as chips, Add hours) in one box */ +.storefront-schedule { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.storefront-schedule__empty { + margin: 0; + color: #6b7280; + font-size: 0.8125rem; +} + +body[data-theme='dark'] .storefront-schedule__empty { + color: #9ca3af; +} + +.storefront-schedule__week { + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-schedule__week { + border-color: #374151; +} + +.storefront-schedule__day { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + min-height: 2.5rem; + padding: 0.375rem 0.75rem; +} + +.storefront-schedule__day + .storefront-schedule__day { + border-top: 1px solid #f3f4f6; +} + +body[data-theme='dark'] .storefront-schedule__day + .storefront-schedule__day { + border-color: var(--sf-hairline); +} + +.storefront-schedule__name { + color: #374151; + font-size: 0.8125rem; + font-weight: 500; +} + +body[data-theme='dark'] .storefront-schedule__name { + color: #e5e7eb; +} + +.storefront-schedule__slots { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + min-width: 0; +} + +.storefront-schedule__slot { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.375rem 0.125rem 0.5rem; + border-radius: 9999px; + color: #1e40af; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + background-color: #dbeafe; +} + +body[data-theme='dark'] .storefront-schedule__slot { + color: #bfdbfe; + background-color: rgb(30 64 175 / 45%); +} + +.storefront-schedule__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 9999px; + opacity: 0.7; +} + +.storefront-schedule__remove:hover { + opacity: 1; + background-color: rgb(0 0 0 / 10%); +} + +.storefront-schedule__none { + color: #9ca3af; + font-size: 0.75rem; +} + +.storefront-schedule__add { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: #2563eb; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; +} + +.storefront-schedule__add:hover { + text-decoration: underline; +} + +body[data-theme='dark'] .storefront-schedule__add { + color: #93c5fd; +} + +/* Product form: an inline empty state sits in the section, not centred in a tall box */ +.storefront-panel-empty.storefront-panel-empty--inline { + padding: 1rem; + border: 1px dashed #d1d5db; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-panel-empty.storefront-panel-empty--inline { + border-color: #374151; +} + +/* Product form: video links as rows with a thumbnail */ +.storefront-product-videos { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.storefront-product-videos__head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.storefront-product-videos__row { + display: grid; + grid-template-columns: 4rem minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.storefront-product-videos__thumb { + display: flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 2.25rem; + overflow: hidden; + border-radius: 0.25rem; + color: #9ca3af; + background-color: #f3f4f6; +} + +body[data-theme='dark'] .storefront-product-videos__thumb { + background-color: #111827; +} + +.storefront-product-videos__thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.storefront-product-videos__row.is-invalid .form-input { + border-color: #f59e0b; +} + +.storefront-product-videos__error { + margin: -0.25rem 0 0 4.5rem; + color: #b45309; + font-size: 0.75rem; +} + +body[data-theme='dark'] .storefront-product-videos__error { + color: #fcd34d; +} + +/* Product form: variants and add-on groups share one group anatomy */ +.storefront-option-groups { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.storefront-option-group { + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-option-group { + border-color: #374151; +} + +.storefront-option-group__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: #f9fafb; +} + +body[data-theme='dark'] .storefront-option-group__head { + background-color: var(--sf-raised); +} + +.storefront-option-group__title { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + font-size: 0.875rem; +} + +.storefront-option-group__meta { + color: #6b7280; + font-size: 0.75rem; +} + +body[data-theme='dark'] .storefront-option-group__meta { + color: #9ca3af; +} + +.storefront-option-group__actions { + display: flex; + flex: 0 0 auto; + gap: 0.25rem; +} + +.storefront-option-group__table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.storefront-option-group__table th { + padding: 0.375rem 0.75rem; + color: #6b7280; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-align: left; + text-transform: uppercase; +} + +body[data-theme='dark'] .storefront-option-group__table th { + color: #9ca3af; +} + +.storefront-option-group__table td { + padding: 0.25rem 0.75rem; + border-top: 1px solid #f3f4f6; + vertical-align: middle; +} + +body[data-theme='dark'] .storefront-option-group__table td { + border-color: var(--sf-hairline); +} + +.storefront-option-group__cost { + width: 9rem; +} + +.storefront-option-group__remove { + width: 2.75rem; + text-align: right; +} + +.storefront-option-group__empty { + padding: 0.75rem; + color: #6b7280; + font-size: 0.8125rem; +} + +body[data-theme='dark'] .storefront-option-group__empty { + color: #9ca3af; +} + +.storefront-option-group__add { + margin: 0.25rem 0.75rem 0.625rem; +} + +.storefront-option-group__addon { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.75rem; + border-top: 1px solid #f3f4f6; + cursor: pointer; +} + +body[data-theme='dark'] .storefront-option-group__addon { + border-color: var(--sf-hairline); +} + +.storefront-option-group__addon.is-hidden .storefront-option-group__addon-text, +.storefront-option-group__addon.is-hidden .storefront-option-group__addon-price { + opacity: 0.55; +} + +.storefront-option-group__addon-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.storefront-option-group__addon-name { + overflow: hidden; + font-size: 0.8125rem; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-option-group__addon-description { + overflow: hidden; + color: #6b7280; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-option-group__addon-description { + color: #9ca3af; +} + +.storefront-option-group__addon-price { + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; +} + +/* Product form: translations as language chips over the fields of the chosen language */ +.storefront-product-translations { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.storefront-product-translations__languages { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; +} + +.storefront-product-translations__language { + display: inline-flex; + align-items: center; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 9999px; + font-size: 0.8125rem; +} + +body[data-theme='dark'] .storefront-product-translations__language { + border-color: #374151; +} + +.storefront-product-translations__language > button:first-child { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.1875rem 0.375rem 0.1875rem 0.75rem; +} + +.storefront-product-translations__language.is-active { + border-color: #2563eb; + color: #1d4ed8; + background-color: #eff6ff; +} + +body[data-theme='dark'] .storefront-product-translations__language.is-active { + border-color: #3b82f6; + color: #bfdbfe; + background-color: rgb(30 64 175 / 35%); +} + +.storefront-product-translations__code { + color: #9ca3af; + font-size: 0.6875rem; + text-transform: uppercase; +} + +.storefront-product-translations__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.125rem; + border-radius: 9999px; + opacity: 0.6; +} + +.storefront-product-translations__remove:hover { + opacity: 1; + background-color: rgb(0 0 0 / 8%); +} + +.storefront-product-translations__add .form-select { + min-width: 11rem; + height: 1.875rem; + padding-top: 0; + padding-bottom: 0; + font-size: 0.8125rem; +} + +.storefront-product-translations__fields { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-product-translations__fields { + border-color: #374151; +} + +.storefront-product-translations__custom { + display: grid; + grid-template-columns: 10rem minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.storefront-product-translations__custom--new { + grid-template-columns: minmax(0, 1fr) auto; +} + +.storefront-product-translations__key { + overflow: hidden; + color: #6b7280; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-product-translations__key { + color: #9ca3af; +} diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 53d2928d..0dbf7976 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -160,7 +160,7 @@

{{t "storefront.catalogs.editor.hours-lede"}}

- +
{{else if (eq this.activeSection "served-by")}}
diff --git a/addon/templates/settings/locations.hbs b/addon/templates/settings/locations.hbs index 3e7b5114..b731912f 100644 --- a/addon/templates/settings/locations.hbs +++ b/addon/templates/settings/locations.hbs @@ -20,7 +20,7 @@

{{t "storefront.settings.locations.store-hour"}}

- + {{/each}}
diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 14b65794..2938ed29 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -343,7 +343,8 @@ storefront: no-categories: No categories for this network create-category: Create a new category schedule-manager: - add-hours: Add Hours + add-hours: Add hours + no-hours: No hours add-new-hours-for-day: Add new hours for {day} new-hours-added-for-day: New hours added for {day} you-wish-to-remove-these-hour: Are you sure you wish to remove these hours? @@ -1323,13 +1324,16 @@ storefront: media: Images and videos variants: Variants addons: Add-ons + translations: Translations fields: name: Name + name-placeholder: What the product is called in the app, for example Margherita pizza description: Description description-placeholder: What the customer reads in the app category: Category category-placeholder: Select a category sku: SKU + sku-placeholder: For example PIZ-MARG-12 sku-help: Optional; shown on order lines and exports. tags: Tags tags-placeholder: Add tags @@ -1337,7 +1341,7 @@ storefront: price-help: What the customer pays for one unit. sale-price: Sale price sale-price-help: Charged instead of the price while the product is on sale. - video-placeholder: Enter a YouTube video URL + video-placeholder: https://www.youtube.com/watch?v=... video-urls: YouTube video URLs video-urls-help: Videos shown on the product in the app. no-options: No options in this variant. @@ -1349,7 +1353,8 @@ storefront: recommended: Recommended, pinned at the top of its category available: Available to order status-help: Drafts stay hidden from the app until they are published. - hours-hint: When the product can be ordered; empty means whenever the store is open. + hours-hint: When the product can be ordered. + hours-empty: No hours set, so the product can be ordered whenever the store is open. variants: hint: Required or optional choices such as size, each option adding to the price. new-option: "New {name} option" @@ -1359,13 +1364,38 @@ storefront: none: No variants. Add one for choices like size or flavour. option-name: Option name option-description: Option description + option: Option + extra-cost: Extra cost + optional: Optional + pick-one: Pick one + options-count: "{count, plural, =0 {no options} one {# option} other {# options}}" + edit: Edit variant + remove: Remove variant + videos: + add: Add video + invalid: Not a YouTube link; paste a watch, share or shorts link. addons: hint: Reusable add-on groups; untick an add-on to hide it for this product. no-description: No description group-empty: No add-ons in this group yet. none: No add-on groups attached. + offered: "{offered} of {total} offered" + hidden: Hidden + remove-group: Remove group more: More more-subtitle: "translations ({translations}), metadata ({metadata} keys)" + metadata: Metadata + metadata-subtitle: "{count, plural, =0 {no keys} one {# key} other {# keys}} for integrations" + translations: + hint: The name and description in other languages; the app shows the customer's language when it exists. + add-language: Add language + remove-language: "Remove {language}" + placeholder: Same as the original + custom-fields: Custom fields + new-field: New field key, for example ingredients + add-field: Add field + help: An empty field shows the original text in the app. + none: Not translated yet. Add a language to translate the name and description. needs-attention: "{count, plural, one {# field needs attention} other {# fields need attention}}" create: Create product creating: Creating the product... From 950e6ceabdcb208948e440676873ffd29f7b32b3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 16:57:01 +0800 Subject: [PATCH 126/133] fix(cards, catalogs): a long card badge ellipsizes before the buttons move; card buttons share one height; the catalog editor's actions header no longer overflows --- addon/styles/storefront-engine.css | 19 ++++++++++++++++++- addon/templates/catalogs/index/edit.hbs | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 4271ddf8..9611e043 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -2915,10 +2915,27 @@ body[data-theme='dark'] .storefront-card__chip-count { .storefront-card__buttons { display: flex; + flex: 0 0 auto; align-items: center; gap: 0.25rem; } +/* A long status badge gives way (ellipsis) before the buttons do */ +.storefront-card__actions > .status-badge { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; +} + +/* Side-by-side card buttons share one 26px height, the menu included */ +.storefront-card__buttons .btn, +.storefront-card__buttons .btn-wrapper > .btn, +.storefront-card__buttons .ember-basic-dropdown-trigger .btn-wrapper > .btn { + height: 1.625rem; + min-height: 0; + padding: 0 0.5rem; +} + .storefront-card__count { color: #4b5563; font-size: 0.75rem; @@ -7893,7 +7910,7 @@ body[data-theme='dark'] .storefront-catalog-product-table td { /* Card footers: a long status badge truncates instead of wrapping to a second line */ .storefront-card__actions .status-badge-inner-wrap { display: inline-block; - max-width: 13rem; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 0dbf7976..4455b542 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -108,7 +108,7 @@ {{t "storefront.catalogs.editor.columns.price"}} {{t "storefront.catalogs.editor.columns.availability"}} {{t "storefront.catalogs.editor.columns.stock"}} - {{t "storefront.catalogs.editor.columns.actions"}} + {{t "storefront.catalogs.editor.columns.actions"}} From 9c59e67f075c35becf12d7266095fd532aed75f7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 17:18:47 +0800 Subject: [PATCH 127/133] test: schedule manager rows, the order store profile and the customers widget identity cell --- .../components/schedule-manager-test.js | 16 +++++++++------- .../components/storefront/order/details-test.js | 4 ++-- .../components/widget/customers-test.js | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/integration/components/schedule-manager-test.js b/tests/integration/components/schedule-manager-test.js index 9563cc15..71ac2222 100644 --- a/tests/integration/components/schedule-manager-test.js +++ b/tests/integration/components/schedule-manager-test.js @@ -17,16 +17,18 @@ module('Integration | Component | schedule-manager', function (hooks) { await render(hbs``); - const panels = this.element.querySelectorAll('.next-content-panel'); - assert.strictEqual(panels.length, 7, 'one panel per weekday'); + const days = this.element.querySelectorAll('[data-test-schedule-day]'); + assert.strictEqual(days.length, 7, 'one row per weekday'); const byDay = {}; - panels.forEach((panel) => { - byDay[panel.querySelector('h4').textContent.trim()] = panel.querySelector('.next-content-panel-body').textContent; + days.forEach((day) => { + byDay[day.getAttribute('data-test-schedule-day')] = [...day.querySelectorAll('.storefront-schedule__slot')].map((slot) => slot.textContent.trim()); }); - assert.ok(byDay.Monday.includes('08:00 AM - 09:00 PM'), 'lowercase monday lands under Monday'); - assert.ok(byDay.Tuesday.includes('10:00 AM - 02:00 PM'), 'capitalised Tuesday lands under Tuesday'); - assert.notOk(byDay.Wednesday.trim(), 'days without hours stay empty'); + assert.deepEqual(byDay.Monday, ['08:00 AM - 09:00 PM'], 'lowercase monday lands under Monday'); + assert.deepEqual(byDay.Tuesday, ['10:00 AM - 02:00 PM'], 'capitalised Tuesday lands under Tuesday'); + assert.deepEqual(byDay.Wednesday, [], 'days without hours have no slots'); + assert.dom('[data-test-schedule-day="Monday"] .storefront-schedule__name').hasText('Monday', 'the day is named in the console language'); + assert.dom('[data-test-schedule-add="Monday"]').exists('each day offers Add hours'); }); }); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index e9a63de0..154d5806 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -99,8 +99,8 @@ module('Integration | Component | storefront/order/details', function (hooks) { `a panel titled ${title} renders` ); } - // The store renders as the shared store pill, named from the order's meta until the record loads. - assert.dom('[data-test-order-store-pill]').hasTextContaining('Tasty Store'); + // The store renders as a store profile, named from the order's meta until the record loads. + assert.dom('[data-test-order-store-name]').hasText('Tasty Store'); assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); assert.dom('.storefront-order-item__image').exists(); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index e58c29d3..8e697b6d 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -69,7 +69,7 @@ module('Integration | Component | widget/customers', function (hooks) { assert.dom('.storefront-widget-count').hasText('1'); assert.dom('.storefront-customers-table').exists(); assert.dom('.storefront-customer-id').hasText('contact_1'); - assert.dom('.storefront-customers-table [data-test-resource-pill-title]').hasText('Ava Chen', 'the customer renders as its pill'); + assert.dom('.storefront-customers-table [data-test-identity-cell]').includesText('Ava Chen', 'the customer renders as the identity cell the orders page uses'); assert.dom('.storefront-customers-table').includesText('ava.chen@example.test'); }); }); From a062d03eb02431e640e33191dfcc671531f9b0be Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 17:56:02 +0800 Subject: [PATCH 128/133] fix(store): order ids line up with their row; identity strip columns rebalanced; the products grid fits below its toolbar with room to scroll the last row clear; card footers sit on the card's bottom edge; card buttons share one line --- addon/controllers/orders/index.js | 2 +- addon/styles/storefront-engine.css | 42 +++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/addon/controllers/orders/index.js b/addon/controllers/orders/index.js index 3472576d..1f62ad0f 100644 --- a/addon/controllers/orders/index.js +++ b/addon/controllers/orders/index.js @@ -128,7 +128,7 @@ export default class OrdersIndexController extends BaseController { valuePath: 'public_id', width: '135px', cellComponent: 'click-to-copy', - cellClassNames: 'storefront-table-muted', + cellClassNames: 'storefront-table-cell-muted', resizable: true, sortable: true, filterable: true, diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 9611e043..af76626b 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -45,12 +45,20 @@ body[data-theme='dark'] .storefront-products-page { } .storefront-products-viewport { + display: flex; + flex-direction: column; min-width: 0; min-height: 0; height: 100%; overflow: hidden; } +/* The toolbar keeps its height; the list takes what is left of the viewport, so its last row + scrolls into view instead of running under the window's edge */ +.storefront-products-viewport > .storefront-products-toolbar { + flex: 0 0 auto; +} + .storefront-product-sidebar { display: flex; min-height: 0; @@ -230,8 +238,8 @@ body[data-theme='dark'] .storefront-product-addon-empty { .storefront-product-workspace { display: flex; + flex: 1 1 auto; min-height: 0; - height: 100%; flex-direction: column; overflow: hidden; } @@ -283,7 +291,7 @@ body[data-theme='dark'] .storefront-product-addon-empty { align-content: start; gap: 0.85rem; overflow: auto; - padding: 0.75rem; + padding: 0.75rem 0.75rem 5.5rem; } .storefront-product-collection.is-empty { @@ -1615,6 +1623,15 @@ body[data-theme='dark'] .storefront-table-muted { color: #9ca3af; } +/* A muted table cell: colour only, so the cell keeps its table layout and vertical centring */ +.storefront-table-cell-muted { + color: #6b7280; +} + +body[data-theme='dark'] .storefront-table-cell-muted { + color: #9ca3af; +} + .storefront-table-actions { display: flex; align-items: center; @@ -2792,6 +2809,18 @@ body[data-theme='dark'] .storefront-order-empty-state { display: flex; flex-direction: column; min-width: 0; + height: 100%; +} + +/* Cards in a row stretch to the tallest; the body takes the slack so every footer sits on the + card's bottom edge */ +.storefront-card > .resource-card-body { + flex: 1 1 auto; +} + +.storefront-card > .resource-card-footer { + flex: 0 0 auto; + margin-top: auto; } .storefront-card__title { @@ -2920,6 +2949,13 @@ body[data-theme='dark'] .storefront-card__chip-count { gap: 0.25rem; } +/* Each button, wrapper or dropdown trigger centres on the same line */ +.storefront-card__buttons > *, +.storefront-card__buttons .ember-basic-dropdown-trigger { + display: inline-flex; + align-items: center; +} + /* A long status badge gives way (ellipsis) before the buttons do */ .storefront-card__actions > .status-badge { flex: 0 1 auto; @@ -8574,7 +8610,7 @@ body[data-theme='dark'] .storefront-empty-row--action:hover:not(:disabled) { /* The customer's name and phone get the widest cell */ .storefront-order-panel-heading .storefront-identity-strip { - grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.1fr) minmax(0, 1.1fr) minmax(0, 0.8fr); + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 0.8fr); } /* Header actions: one 28px height for every button, the ellipsis padded like a button, tight gaps */ From a94605cff3dc80ac5dc274028983dfe7623fbf70 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 18:35:56 +0800 Subject: [PATCH 129/133] fix(network orders): the store cell is one line; a multi-store checkout shows round store logos with the summary on hover (the network itself included); the order and actions columns stay put while scrolling; a reference shows its item count instead of the checkout id --- .../network/orders/cell/reference.hbs | 8 +-- .../storefront/network/orders/cell/stores.hbs | 22 ++++++-- .../storefront/network/orders/cell/stores.js | 17 ++++-- .../networks/index/network/orders.js | 7 ++- .../storefront/network/orders/cells-test.js | 53 +++++++++++++++++++ 5 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/integration/components/storefront/network/orders/cells-test.js diff --git a/addon/components/storefront/network/orders/cell/reference.hbs b/addon/components/storefront/network/orders/cell/reference.hbs index 39a98cf4..e2cf4adf 100644 --- a/addon/components/storefront/network/orders/cell/reference.hbs +++ b/addon/components/storefront/network/orders/cell/reference.hbs @@ -12,13 +12,7 @@ {{@row.public_id}} - {{#if @row.isChild}} - {{t "storefront.networks.orders.items-count" count=(or @row.payload.entities_count @row.item_count 0)}} - {{else if @row.meta.checkout_id}} - {{t "storefront.networks.orders.part-of" checkout=@row.meta.checkout_id}} - {{else}} - {{t "storefront.networks.orders.items-count" count=(or @row.payload.entities_count @row.item_count 0)}} - {{/if}} + {{t "storefront.networks.orders.items-count" count=(or @row.payload.entities_count @row.item_count 0)}} {{/if}} diff --git a/addon/components/storefront/network/orders/cell/stores.hbs b/addon/components/storefront/network/orders/cell/stores.hbs index 862f6633..8c86f6fd 100644 --- a/addon/components/storefront/network/orders/cell/stores.hbs +++ b/addon/components/storefront/network/orders/cell/stores.hbs @@ -1,12 +1,26 @@ -
+
{{#each this.visible as |entry|}} {{#if entry.store}} {{#if this.isStack}} - {{! A checkout group: one tile per store, names on hover, so the row stays one line. }} - {{entry.name}} + {{! A checkout across stores: one round logo per store, the name and summary on hover. }} + + {{entry.name}} + {{else}} - + {{/if}} + {{else if this.isStack}} + {{entry.initial}} {{else}} {{entry.name}} {{/if}} diff --git a/addon/components/storefront/network/orders/cell/stores.js b/addon/components/storefront/network/orders/cell/stores.js index d421269e..3340d3d8 100644 --- a/addon/components/storefront/network/orders/cell/stores.js +++ b/addon/components/storefront/network/orders/cell/stores.js @@ -4,7 +4,7 @@ import { orderStoreId, orderStoreName } from '../../../../../utils/order-groups' const MAX_VISIBLE = 4; /** - * The stores behind a row: one for an order, up to four tiles plus an overflow count for a checkout group. + * The stores behind a row: one pill for an order, up to four round logos plus an overflow count for a checkout group. */ export default class StorefrontNetworkOrdersCellStoresComponent extends Component { get entries() { @@ -13,10 +13,21 @@ export default class StorefrontNetworkOrdersCellStoresComponent extends Componen const ids = row?.isGroup ? (row.storeIds ?? []) : [orderStoreId(row)].filter(Boolean); const names = row?.isGroup ? (row.storeNames ?? []) : [orderStoreName(row)].filter(Boolean); - return ids.map((id, index) => ({ id, store: storesById[id] ?? null, name: storesById[id]?.name ?? names[index] ?? names[0] ?? id })); + return ids.map((id, index) => { + const name = storesById[id]?.name ?? names[index] ?? names[0] ?? id; + + return { + id, + store: storesById[id] ?? null, + name, + initial: String(name ?? '?') + .charAt(0) + .toUpperCase(), + }; + }); } - /** More than one store: tiles only, so a group row keeps a single line. */ + /** More than one store: round logos only, so the row keeps a single line. */ get isStack() { return this.entries.length > 1; } diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index ac01d2bf..9c56f173 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -64,12 +64,15 @@ export default class NetworksIndexNetworkOrdersController extends BaseController })); } + /** The network's stores by id, plus the network itself for orders placed against it directly. */ get storesById() { + const initial = this.network ? { [this.network.public_id]: this.network, [this.network.id]: this.network } : {}; + return this.stores.reduce((map, store) => { map[store.public_id] = store; map[store.id] = store; return map; - }, {}); + }, initial); } /** @@ -170,6 +173,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController width: '175px', resizable: true, sortable: false, + sticky: true, }, { id: 'customer', @@ -238,6 +242,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController width: '160px', sortable: false, filterable: false, + sticky: 'right', resizable: false, searchable: false, }, diff --git a/tests/integration/components/storefront/network/orders/cells-test.js b/tests/integration/components/storefront/network/orders/cells-test.js new file mode 100644 index 00000000..836e3b87 --- /dev/null +++ b/tests/integration/components/storefront/network/orders/cells-test.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/storefront-engine/instance-initializers/register-storefront-resource-descriptors'; + +module('Integration | Component | storefront/network/orders cells', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + initialize(this.owner); + this.set('storesById', { + store_a: { public_id: 'store_a', name: 'Rochor Noodle House', currency: 'SGD', logo_url: 'https://cdn.test/a.png' }, + store_b: { public_id: 'store_b', name: 'Orchard Grocers', currency: 'SGD', logo_url: 'https://cdn.test/b.png' }, + }); + }); + + test('one store reads as a single-line pill, without the currency underneath', async function (assert) { + this.set('row', { meta: { storefront_id: 'store_a', storefront: 'Rochor Noodle House' } }); + + await render(hbs``); + + assert.dom('[data-test-resource-pill-title]').hasText('Rochor Noodle House'); + assert.dom('[data-test-resource-pill-subtitle]').doesNotExist(); + assert.dom('[data-test-order-store-avatar]').doesNotExist(); + }); + + test('a checkout across stores shows one round logo per store, named for screen readers', async function (assert) { + this.set('row', { isGroup: true, storeIds: ['store_a', 'store_b', 'store_x'], storeNames: ['Rochor Noodle House', 'Orchard Grocers', 'Unknown Mart'] }); + + await render(hbs``); + + assert.dom('[data-test-order-stores]').hasClass('storefront-identity-stack--avatars'); + assert.dom('[data-test-order-store-avatar]').exists({ count: 2 }); + assert.dom('[data-test-order-store-avatar]').hasAttribute('aria-label', 'Rochor Noodle House'); + assert.dom('[data-test-pill-online-indicator]').doesNotExist(); + assert.dom('.storefront-identity-stack__initial').hasText('U', 'a store that is not loaded falls back to its initial'); + assert.dom('.storefront-identity-stack__initial').hasAttribute('title', 'Unknown Mart'); + }); + + test('an order reference shows its item count, not the checkout it belongs to', async function (assert) { + this.set('row', { public_id: 'order_1', item_count: 2, meta: { checkout_id: 'chkt_123' } }); + this.set('column', { onView() {}, onToggle() {} }); + + await render(hbs``); + + assert.dom('[data-test-order-reference]').includesText('order_1'); + assert.dom('[data-test-order-reference]').doesNotIncludeText('chkt_123'); + assert.dom('.storefront-order-ref__meta').hasText('2 items'); + }); +}); From 3160a75ff6333c8ee23080f914293a6e5b07281e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 18:35:56 +0800 Subject: [PATCH 130/133] feat(network): a store details page with its week, orders and customers, reached from the stores table, the overview cards and a customer's stores; overview cards four across with the store backdrop and logo; switching to a network lands on its overview without loading the networks list, which loads when shown; the overview skeleton also covers returning to the overview; new order store cards match the promotion choice cards; recent order rows show their hover; the category caret sits at the right --- addon/components/customer-panel/overview.hbs | 19 +- addon/components/customer-panel/overview.js | 18 +- .../storefront/network/overview-skeleton.hbs | 23 +++ .../storefront/network/overview-skeleton.js | 3 + addon/controllers/application.js | 10 +- addon/controllers/networks/index.js | 8 + .../networks/index/network/index.js | 4 + .../networks/index/network/store.js | 133 +++++++++++++ .../networks/index/network/stores.js | 28 +-- addon/routes.js | 1 + addon/routes/networks/index.js | 33 ++- addon/routes/networks/index/network/store.js | 31 +++ addon/styles/storefront-engine.css | 188 ++++++++++++++++-- addon/templates/networks/index.hbs | 8 +- addon/templates/networks/index/loading.hbs | 25 +-- .../networks/index/network/index-loading.hbs | 2 + .../networks/index/network/index.hbs | 11 +- .../networks/index/network/store.hbs | 126 ++++++++++++ addon/templates/networks/loading.hbs | 23 +-- .../storefront/network/overview-skeleton.js | 1 + .../Http/Controllers/NetworkController.php | 1 + tests/unit/controllers/application-test.js | 10 +- tests/unit/routes/networks/index-test.js | 33 ++- translations/en-us.yaml | 9 +- 24 files changed, 644 insertions(+), 104 deletions(-) create mode 100644 addon/components/storefront/network/overview-skeleton.hbs create mode 100644 addon/components/storefront/network/overview-skeleton.js create mode 100644 addon/controllers/networks/index/network/store.js create mode 100644 addon/routes/networks/index/network/store.js create mode 100644 addon/templates/networks/index/network/index-loading.hbs create mode 100644 addon/templates/networks/index/network/store.hbs create mode 100644 app/components/storefront/network/overview-skeleton.js diff --git a/addon/components/customer-panel/overview.hbs b/addon/components/customer-panel/overview.hbs index cef6e358..1f4cab6a 100644 --- a/addon/components/customer-panel/overview.hbs +++ b/addon/components/customer-panel/overview.hbs @@ -66,11 +66,20 @@ {{#if this.stores.length}}
{{#each this.stores as |store|}} -
- -
{{n-a store.name store.public_id}}
- {{t "storefront.customers.panel.orders-count" count=store.orders}} -
+ {{#if store.canOpen}} + + {{else}} +
+ +
{{n-a store.name store.public_id}}
+ {{t "storefront.customers.panel.orders-count" count=store.orders}} +
+ {{/if}} {{/each}}
{{else}} diff --git a/addon/components/customer-panel/overview.js b/addon/components/customer-panel/overview.js index 1bdecfe3..245e7ea1 100644 --- a/addon/components/customer-panel/overview.js +++ b/addon/components/customer-panel/overview.js @@ -14,6 +14,7 @@ export default class CustomerPanelOverviewComponent extends Component { @service storefrontOrderActions; @service notifications; @service hostRouter; + @service contextPanel; @tracked orders = []; constructor() { @@ -43,8 +44,11 @@ export default class CustomerPanelOverviewComponent extends Component { return this.places.map((place) => ({ place, isDefault: place.id === this.defaultPlaceId })); } + /** Where the customer ordered; in a network, each member store opens its details (an order placed on the network itself does not). */ get stores() { - return this.insights?.stores ?? []; + const canOpen = this.storefront.isNetworkContext; + + return (this.insights?.stores ?? []).map((store) => ({ ...store, canOpen: canOpen && String(store.public_id ?? '').startsWith('store_') })); } /** The segments whose rules this customer matches, from the insights endpoint. */ @@ -60,6 +64,18 @@ export default class CustomerPanelOverviewComponent extends Component { return this.hostRouter.transitionTo('console.storefront.promotions.segments'); } + /** In a network, a store the customer ordered from opens that store's details. */ + @action openStore(store) { + const network = this.storefront.activeNetwork; + + if (!network || !store?.public_id) { + return; + } + + this.contextPanel.clear(); + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', network.public_id, store.public_id); + } + get showStores() { return this.storefront.isNetworkContext || this.stores.length > 1; } diff --git a/addon/components/storefront/network/overview-skeleton.hbs b/addon/components/storefront/network/overview-skeleton.hbs new file mode 100644 index 00000000..c0138c35 --- /dev/null +++ b/addon/components/storefront/network/overview-skeleton.hbs @@ -0,0 +1,23 @@ +{{! The network overview while it loads: header, five KPI tiles, member cards four to a row, invitations and recent orders. }} + + +
+ + +
+ +
+
+ + +
+ +
+
+
+ + +
+ +
+
diff --git a/addon/components/storefront/network/overview-skeleton.js b/addon/components/storefront/network/overview-skeleton.js new file mode 100644 index 00000000..7eb58e4c --- /dev/null +++ b/addon/components/storefront/network/overview-skeleton.js @@ -0,0 +1,3 @@ +import templateOnlyComponent from '@ember/component/template-only'; + +export default templateOnlyComponent(); diff --git a/addon/controllers/application.js b/addon/controllers/application.js index 93300bfb..f7e7aef7 100644 --- a/addon/controllers/application.js +++ b/addon/controllers/application.js @@ -36,14 +36,14 @@ export default class ApplicationController extends Controller { get networkNavigationItems() { const publicId = this.activeNetwork.public_id; - const networkItem = (key, item) => { + const networkItem = (key, { alsoActiveOn = [], ...item }) => { const route = `console.storefront.networks.index.network.${key}`; return { id: `network:${publicId}:${key}`, ...item, // These routes share one dynamic segment, so route-name matching alone cannot tell networks apart. - activeWhen: () => this.hostRouter.isActive(route, publicId), + activeWhen: () => [route, ...alsoActiveOn.map((name) => `console.storefront.networks.index.network.${name}`)].some((name) => this.hostRouter.isActive(name, publicId)), onClick: () => this.hostRouter.transitionTo(route, publicId), }; }; @@ -63,6 +63,8 @@ export default class ApplicationController extends Controller { badge: this.networkPendingCount || null, permission: 'storefront view network', keywords: ['stores', 'members', 'invite', 'categories'], + // A store's details page sits under Stores. + alsoActiveOn: ['store'], }), networkItem('trucks', { label: this.intl.t('storefront.common.food-trucks'), @@ -282,8 +284,8 @@ export default class ApplicationController extends Controller { } @action switchActiveNetwork(network) { - // Entering the network route is what sets the network context. - return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', network.public_id); + // Entering the network route is what sets the network context; a switch lands on the overview. + return this.hostRouter.transitionTo('console.storefront.networks.index.network.index', network.public_id); } @action diff --git a/addon/controllers/networks/index.js b/addon/controllers/networks/index.js index c183e459..ce67a68d 100644 --- a/addon/controllers/networks/index.js +++ b/addon/controllers/networks/index.js @@ -7,6 +7,14 @@ import { isBlank } from '@ember/utils'; import { timeout, task } from 'ember-concurrency'; export default class NetworksIndexController extends BaseController { + /** + * Whether the networks list itself is on screen. A network page (and its loading state) only + * renders the outlet, so the list never flashes in while a network loads. + */ + get isListRoute() { + return this.hostRouter.currentRouteName === 'console.storefront.networks.index.index'; + } + /** * Inject the `NetworksIndexNetworkStoresController` controller * diff --git a/addon/controllers/networks/index/network/index.js b/addon/controllers/networks/index/network/index.js index 630b9557..ac76f72b 100644 --- a/addon/controllers/networks/index/network/index.js +++ b/addon/controllers/networks/index/network/index.js @@ -55,6 +55,10 @@ export default class NetworksIndexNetworkIndexController extends Controller { return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', this.network.public_id, { queryParams: { storeQuery: member.name } }); } + @action viewStoreDetails(member) { + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', this.network.public_id, member.public_id); + } + @action openMemberStore(store) { const application = getOwner(this).lookup('controller:application'); diff --git a/addon/controllers/networks/index/network/store.js b/addon/controllers/networks/index/network/store.js new file mode 100644 index 00000000..459eaad0 --- /dev/null +++ b/addon/controllers/networks/index/network/store.js @@ -0,0 +1,133 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import { getOwner } from '@ember/application'; + +/** + * A member store seen from its network: who it is, how it is doing in the network this week, + * and its orders and customers through the network. + */ +export default class NetworksIndexNetworkStoreController extends Controller { + @service fetch; + @service store; + @service intl; + @service hostRouter; + @service storefront; + @service storefrontOrderActions; + @service notifications; + @service contextPanel; + + @tracked tab = 'orders'; + @tracked member = null; + @tracked orders = []; + @tracked customers = []; + + get network() { + return this.storefront.activeNetwork; + } + + get tabs() { + return [ + { id: 'orders', label: this.intl.t('storefront.common.orders'), count: this.orders.meta?.total ?? this.orders.length, isActive: this.tab === 'orders' }, + { id: 'customers', label: this.intl.t('storefront.common.customers'), count: this.customers.meta?.total ?? this.customers.length, isActive: this.tab === 'customers' }, + ]; + } + + /** Category in the network · currency · id. */ + get subtitle() { + // The category comes with the membership, so it waits for the load rather than reading "No category". + const category = this.member ? (this.member.category ?? this.intl.t('storefront.networks.overview.no-category')) : null; + + return [category, this.model?.currency, this.model?.public_id].filter(Boolean).join(' · '); + } + + get currency() { + return this.model?.currency ?? this.network?.currency; + } + + /** The membership as the network sees it: category, status and this week's numbers. */ + get badge() { + const status = this.member?.membership_status ?? 'active'; + + if (status === 'suspended') { + return { status: 'danger', label: this.intl.t('storefront.networks.index.network.stores.suspended') }; + } + + if (status === 'pending') { + return { status: 'warning', label: this.intl.t('storefront.networks.index.network.stores.awaiting-approval') }; + } + + return this.model?.online ? { status: 'online', label: this.intl.t('storefront.common.online') } : { status: 'offline', label: this.intl.t('storefront.common.offline') }; + } + + get application() { + return getOwner(this).lookup('controller:application'); + } + + /** This organisation's own record of the store, when it runs it, so the console can switch into it. */ + get ownStore() { + const stores = this.application?.model?.stores; + const list = stores?.toArray?.() ?? Array.from(stores ?? []); + + return list.find((record) => record.public_id === this.model?.public_id) ?? null; + } + + @task({ restartable: true }) *load(storeRecord) { + const network = this.network; + this.member = null; + this.orders = []; + this.customers = []; + + if (!network || !storeRecord) { + return; + } + + const scope = { storefront: storeRecord.public_id, network: network.public_id }; + + try { + const [overview, ordersResponse, customers] = yield Promise.all([ + this.fetch.get(`networks/${network.id}/overview`, {}, { namespace: 'storefront/int/v1' }), + this.fetch.get('orders', { ...scope, limit: 25, sort: '-created_at' }, { namespace: 'storefront/int/v1' }), + this.fetch.get('customers', { ...scope, limit: 25, sort: '-created_at' }, { namespace: 'storefront/int/v1' }), + ]); + + this.member = (overview?.members ?? []).find((member) => member.public_id === storeRecord.public_id) ?? null; + + const orders = this.fetch.normalizeModel(ordersResponse, 'orders'); + orders.meta = ordersResponse?.meta; + this.orders = orders; + // The customer model has no order count, so each row keeps the count beside its record. + const rows = (customers?.customers ?? []).map((customer) => ({ record: this.store.push(this.store.normalize('customer', customer)), orders: customer.orders ?? 0 })); + rows.meta = customers?.meta ?? {}; + this.customers = rows; + } catch (error) { + this.notifications.serverError(error); + } + } + + @action selectTab(id) { + this.tab = id; + } + + @action viewOrder(order) { + return this.storefrontOrderActions.viewOrder(order, { onChange: () => this.load.perform(this.model) }); + } + + /** Opens the customer's panel here, as the network customers page does. */ + @action viewCustomer(customer) { + this.contextPanel.focus(customer, 'viewing'); + } + + @action backToStores() { + return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', this.network.public_id); + } + + /** Switches the console into this store's own context. */ + @action manageStore() { + if (this.ownStore) { + return this.application?.switchActiveStore?.(this.ownStore); + } + } +} diff --git a/addon/controllers/networks/index/network/stores.js b/addon/controllers/networks/index/network/stores.js index 20d21d11..b700f453 100644 --- a/addon/controllers/networks/index/network/stores.js +++ b/addon/controllers/networks/index/network/stores.js @@ -228,6 +228,15 @@ export default class NetworksIndexNetworkStoresController extends BaseController * * @var {Array} */ + /** A member's name opens its details inside the network; invitations have no store yet. */ + @action viewStoreDetails(row) { + if (!row || row.isInvitation || !row.public_id) { + return; + } + + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', this.network.public_id, row.public_id); + } + get columns() { return [ { @@ -238,6 +247,7 @@ export default class NetworksIndexNetworkStoresController extends BaseController resourceType: 'store', resourcePath: (row) => (row.isInvitation ? buildIdentityStub(row, { type: 'store', nameKey: 'email', icon: 'at' }) : row), popover: true, + action: this.viewStoreDetails, width: '160px', resizable: true, sortable: true, @@ -792,24 +802,6 @@ export default class NetworksIndexNetworkStoresController extends BaseController }); } - /** - * Displays a modal to view the details of a specified store. - * The modal includes the store's name and a "Done" button. - * - * @action - * @param {StoreModel} store - The store object whose details are to be viewed. - * @param {Object} [options={}] - Additional options for the modal. - */ - @action viewStoreDetails(store, options = {}) { - this.modalsManager.show('modals/store-details', { - title: this.intl.t('storefront.networks.index.network.stores.viewing-storefront', { storeName: store.name }), - acceptButtonText: this.intl.t('storefront.networks.index.network.stores.done'), - hideDeclineButton: true, - store, - ...options, - }); - } - /** * Displays a modal to edit a specified store's details. * Allows the user to make changes and confirm to save them. diff --git a/addon/routes.js b/addon/routes.js index a68ff09e..ba82017c 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -41,6 +41,7 @@ export default buildRoutes(function () { this.route('index', { path: '/' }, function () { this.route('network', { path: '/:public_id' }, function () { this.route('stores'); + this.route('store', { path: '/stores/:store_id' }); this.route('trucks'); this.route('customers'); this.route('orders', function () { diff --git a/addon/routes/networks/index.js b/addon/routes/networks/index.js index 4ca0a47e..0d292a31 100644 --- a/addon/routes/networks/index.js +++ b/addon/routes/networks/index.js @@ -1,6 +1,8 @@ import StorefrontListRoute from '@fleetbase/storefront-engine/utils/list-route'; import { inject as service } from '@ember/service'; +const LIST_ROUTE = 'console.storefront.networks.index.index'; + export default class NetworksIndexRoute extends StorefrontListRoute { @service store; @service intl; @@ -17,6 +19,16 @@ export default class NetworksIndexRoute extends StorefrontListRoute { updated_at: { refreshModel: true }, }; + constructor() { + super(...arguments); + this.hostRouter.on('routeDidChange', this, this.loadSkippedList); + } + + willDestroy() { + this.hostRouter.off('routeDidChange', this, this.loadSkippedList); + super.willDestroy(...arguments); + } + beforeModel() { if (this.abilities.cannot('storefront list network')) { this.notifications.warning(this.intl.t('common.unauthorized-access')); @@ -24,7 +36,26 @@ export default class NetworksIndexRoute extends StorefrontListRoute { } } - model(params) { + /** + * The networks list is only shown on its own page. Entering a network passes through this + * route too, so it skips the list (every network with its gateways and channels) then, which + * made switching into a network slow. + */ + model(params, transition) { + if (transition?.to?.name && transition.to.name !== LIST_ROUTE) { + this.skippedList = true; + return []; + } + + this.skippedList = false; return this.store.query('network', { with_gateways: 1, with_notification_channels: 1, ...params }); } + + /** Arriving on the list from inside a network: this route stayed active, so load the list now. */ + loadSkippedList(transition) { + if (this.skippedList && transition?.to?.name === LIST_ROUTE) { + this.skippedList = false; + this.refresh(); + } + } } diff --git a/addon/routes/networks/index/network/store.js b/addon/routes/networks/index/network/store.js new file mode 100644 index 00000000..effcb943 --- /dev/null +++ b/addon/routes/networks/index/network/store.js @@ -0,0 +1,31 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +/** + * A member store seen from its network: its numbers, orders and customers inside the network. + */ +export default class NetworksIndexNetworkStoreRoute extends Route { + @service store; + @service intl; + @service abilities; + @service hostRouter; + @service notifications; + + beforeModel() { + if (this.abilities.cannot('storefront view store')) { + this.notifications.warning(this.intl.t('common.unauthorized-access')); + return this.hostRouter.transitionTo('console.storefront'); + } + } + + model({ store_id }) { + return this.store.findRecord('store', store_id); + } + + setupController(controller, model) { + super.setupController(...arguments); + // Another store opens on its orders, not on the tab the last one was left on. + controller.tab = 'orders'; + controller.load.perform(model); + } +} diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index af76626b..dfeb11d0 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -3395,6 +3395,10 @@ body[data-theme='dark'] .storefront-network-page__meta { gap: 0.75rem; } +.storefront-stat-grid.storefront-stat-grid--4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + @media (width <= 1200px) { .storefront-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -3497,6 +3501,28 @@ body[data-theme='dark'] .storefront-member-row--link { background-color: var(--sf-raised); } +/* A row that opens something reads as one: pointer, hover tint and a chevron */ +button.storefront-member-row--link { + width: 100%; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease; +} + +button.storefront-member-row--link:hover { + background-color: #f3f4f6; +} + +body[data-theme='dark'] button.storefront-member-row--link:hover { + background-color: #2f3b4f; +} + +.storefront-member-row__chevron { + flex: 0 0 auto; + margin-left: 0.5rem; + color: #9ca3af; +} + .storefront-member-row__logo { width: 2rem; height: 2rem; @@ -3904,12 +3930,64 @@ body[data-theme='dark'] .storefront-order-driver__muted { gap: 0.25rem; } -.storefront-identity-stack--tiles { +.storefront-identity-stack--avatars { flex-wrap: nowrap; + gap: 0; } -.storefront-identity-stack--tiles .storefront-identity-stack__pill { - min-width: 0; +/* A multi-store row: overlapping round logos, the pill's text kept for screen readers only. */ +.storefront-identity-stack--avatars > * + * { + margin-left: -0.375rem; +} + +.storefront-identity-stack__avatar.fleetbase-pill, +.storefront-identity-stack__avatar { + flex: 0 0 auto; + gap: 0; + padding: 0; + border: 0; + background: transparent; +} + +.storefront-identity-stack__avatar .fleetbase-pill-body { + gap: 0; +} + +.storefront-identity-stack__avatar img { + width: 1.5rem; + height: 1.5rem; + object-fit: cover; + border-radius: 9999px; + box-shadow: 0 0 0 2px #fff; +} + +.storefront-identity-stack__avatar:hover { + position: relative; + z-index: 1; +} + +.storefront-identity-stack__initial { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + background-color: #e5e7eb; + color: #374151; + box-shadow: 0 0 0 2px #fff; +} + +body[data-theme='dark'] .storefront-identity-stack__avatar img, +body[data-theme='dark'] .storefront-identity-stack__initial { + box-shadow: 0 0 0 2px #111827; +} + +body[data-theme='dark'] .storefront-identity-stack__initial { + background-color: #374151; + color: #e5e7eb; } .storefront-identity-stack__text { @@ -3920,6 +3998,7 @@ body[data-theme='dark'] .storefront-order-driver__muted { } .storefront-identity-stack__overflow { + margin-left: 0.25rem; padding: 0 0.375rem; border-radius: 9999px; font-size: 0.6875rem; @@ -6149,13 +6228,14 @@ body[data-theme='dark'] .storefront-create-order__picked { grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); } +/* Store, location and address cards share the promotion builder's choice card: quiet border, raised in dark, blue when picked. */ .storefront-create-order__place { display: flex; width: 100%; align-items: center; gap: 0.625rem; padding: 0.5rem 0.625rem; - border: 1px solid #d1d5db; + border: 1px solid #e5e7eb; border-radius: 0.375rem; text-align: left; font-size: 0.8125rem; @@ -6163,17 +6243,27 @@ body[data-theme='dark'] .storefront-create-order__picked { } body[data-theme='dark'] .storefront-create-order__place { + border-color: #374151; + background-color: #1f2937; +} + +.storefront-create-order__place:not(.is-static, .is-selected):hover { + border-color: #93c5fd; +} + +body[data-theme='dark'] .storefront-create-order__place:not(.is-static, .is-selected):hover { border-color: #4b5563; - background-color: #111827; + background-color: var(--sf-raised); } .storefront-create-order__place.is-selected { border-color: #2563eb; - box-shadow: 0 0 0 2px #bfdbfe; + background-color: #dbeafe; } body[data-theme='dark'] .storefront-create-order__place.is-selected { - box-shadow: 0 0 0 2px rgb(37 99 235 / 40%); + border-color: #3b82f6; + background-color: rgb(30 58 138 / 35%); } .storefront-create-order__tile { @@ -7317,12 +7407,59 @@ body[data-theme='dark'] .storefront-table__tile { gap: 0.875rem; } -@media (width <= 1279px) { +@media (width <= 1023px) { .storefront-member-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +/* Member card: the store's backdrop fills the media band with its logo on top */ +.storefront-member-card__identity { + display: block; + width: 100%; + min-width: 0; + text-align: left; +} + +.storefront-member-card__media.resource-card-body { + position: relative; + height: 7.5rem; + padding: 0; + background-color: #e5e7eb; + background-image: linear-gradient(135deg, #dbeafe, #ede9fe); + background-position: center; + background-size: cover; +} + +body[data-theme='dark'] .storefront-member-card__media.resource-card-body { + background-color: #111827; + background-image: linear-gradient(135deg, #1e3a8a, #312e81); +} + +.storefront-member-card__media-link { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: linear-gradient(0deg, rgb(0 0 0 / 25%), rgb(0 0 0 / 5%)); +} + +.storefront-member-card__logo { + width: 3.5rem; + height: 3.5rem; + border: 3px solid #fff; + border-radius: 0.75rem; + background-color: #fff; + object-fit: cover; + box-shadow: 0 4px 12px rgb(0 0 0 / 25%); +} + +body[data-theme='dark'] .storefront-member-card__logo { + border-color: var(--sf-surface); + background-color: var(--sf-surface); +} + @media (width <= 640px) { .storefront-member-cards { grid-template-columns: minmax(0, 1fr); @@ -7498,11 +7635,32 @@ body[data-theme='dark'] .storefront-filter-menu__group { color: #2563eb; } -/* Inline category picker in the Stores table */ -.storefront-category-cell__button { +/* Inline category picker in the Stores table: the button fills the cell, the caret on its right edge */ +.ember-basic-dropdown-trigger:has(.storefront-category-cell__button), +.btn-wrapper:has(.storefront-category-cell__button) { + display: flex; + width: 100%; +} + +.btn.storefront-category-cell__button { + display: flex; + justify-content: space-between; + width: 100%; max-width: 100%; } +.storefront-category-cell__button > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-category-cell__button > svg, +.storefront-category-cell__button > .svg-inline--fa { + flex: 0 0 auto; + margin-left: auto; +} + .storefront-category-cell__button.is-empty { color: #6b7280; font-style: italic; @@ -9703,7 +9861,7 @@ body[data-theme='dark'] .storefront-panel-empty__help { } .storefront-customer-order-row:hover { - background-color: #f9fafb; + background-color: #f3f4f6; } body[data-theme='dark'] .storefront-customer-order-row:hover { @@ -9844,7 +10002,7 @@ body[data-theme='dark'] .storefront-check.is-radio.is-checked { } .storefront-table--rows tbody tr.is-clickable:hover td { - background-color: #f9fafb; + background-color: #f3f4f6; } body[data-theme='dark'] .storefront-table--rows tbody tr.is-clickable:hover td { @@ -9855,12 +10013,6 @@ body[data-theme='dark'] .storefront-table--rows tbody tr.is-clickable:hover td { max-width: 14rem; } -/* Network overview member cards: four to a row with a shorter logo band */ -.storefront-member-card .storefront-card__media-body { - flex-basis: 7.5rem; - height: 7.5rem; -} - /* Schedule manager: the week as compact rows (day, slots as chips, Add hours) in one box */ .storefront-schedule { display: flex; diff --git a/addon/templates/networks/index.hbs b/addon/templates/networks/index.hbs index 84698c40..ed7ab27c 100644 --- a/addon/templates/networks/index.hbs +++ b/addon/templates/networks/index.hbs @@ -1,6 +1,4 @@ -{{#if this.storefront.isNetworkContext}} - {{outlet}} -{{else}} +{{#if this.isListRoute}} - - {{row.member.name}} + +
@@ -60,6 +64,7 @@
+ {{t "storefront.networks.overview.view-store"}} {{t "storefront.networks.overview.view-on-stores"}} {{#if row.ownStore}} {{t "storefront.networks.overview.open-store"}} diff --git a/addon/templates/networks/index/network/store.hbs b/addon/templates/networks/index/network/store.hbs new file mode 100644 index 00000000..7d84f7c7 --- /dev/null +++ b/addon/templates/networks/index/network/store.hbs @@ -0,0 +1,126 @@ + + + {{/each}} +
+
+ {{#if this.load.isRunning}} + + {{else if (eq this.tab "orders")}} + + + + + + + + + + + + + {{#each this.orders as |order|}} + + + + + + + + + {{else}} + + {{/each}} + +
{{t "storefront.common.order"}}{{t "storefront.common.customer"}}{{t "storefront.networks.orders.columns.fulfillment"}}{{t "storefront.networks.orders.columns.placed"}}{{t "storefront.common.status"}}{{t "storefront.common.total"}}
{{or order.tracking order.public_id}}{{format-date-fns order.created_at "d MMM, HH:mm"}}{{format-currency order.meta.total order.meta.currency}}
{{t "storefront.networks.store-details.no-orders"}}
+ {{else}} + + + + + + + + + + + {{#each this.customers as |row|}} + + + + + + + {{else}} + + {{/each}} + +
{{t "storefront.common.name"}}{{t "storefront.common.email"}}{{t "storefront.common.phone"}}{{t "storefront.common.orders"}}
{{n-a row.record.email}}{{n-a row.record.phone}}{{row.orders}}
{{t "storefront.networks.store-details.no-customers"}}
+ {{/if}} +
+
+ diff --git a/addon/templates/networks/loading.hbs b/addon/templates/networks/loading.hbs index ad9209d9..1cbe3e93 100644 --- a/addon/templates/networks/loading.hbs +++ b/addon/templates/networks/loading.hbs @@ -1,23 +1,2 @@ {{! Entering the networks section lands on a network overview. }} - - -
- - -
- -
-
- - -
- -
-
-
- - -
- -
-
+ diff --git a/app/components/storefront/network/overview-skeleton.js b/app/components/storefront/network/overview-skeleton.js new file mode 100644 index 00000000..900faabb --- /dev/null +++ b/app/components/storefront/network/overview-skeleton.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/network/overview-skeleton'; diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index fd32f8f6..214c543b 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -432,6 +432,7 @@ public function overview(string $id) 'public_id' => $store->public_id, 'name' => $store->name, 'logo_url' => $store->logo_url, + 'backdrop_url' => $store->backdrop_url, 'online' => (bool) $store->online, 'currency' => $store->currency, 'category_uuid' => $store->pivot->category_uuid ?? null, diff --git a/tests/unit/controllers/application-test.js b/tests/unit/controllers/application-test.js index de5565a3..35274589 100644 --- a/tests/unit/controllers/application-test.js +++ b/tests/unit/controllers/application-test.js @@ -292,17 +292,21 @@ module('Unit | Controller | application', function (hooks) { assert.true(items[2].activeWhen(), 'the trucks section is active on the network trucks route'); assert.false(items[1].activeWhen(), 'the stores section is not'); + hostRouter.isActive = (route, publicId) => route === 'console.storefront.networks.index.network.store' && publicId === 'network_123'; + assert.true(items[1].activeWhen(), "a member store's details page keeps the stores section active"); + assert.false('alsoActiveOn' in items[1], 'the extra routes stay out of the sidebar item'); + items[3].onClick(); assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.orders', 'network_123']], 'sections transition with the network id'); }); - test('switching to a network enters its stores page', function (assert) { + test('switching to a network enters its overview', function (assert) { const controller = this.owner.lookup('controller:application'); const hostRouter = this.owner.lookup('service:host-router'); controller.switchActiveNetwork({ id: 'network_uuid', public_id: 'network_123' }); - assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.stores', 'network_123']]); + assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.index', 'network_123']]); }); test('switching to a store from a network lands on the store dashboard', async function (assert) { @@ -327,6 +331,6 @@ module('Unit | Controller | application', function (hooks) { assert.strictEqual(storefront.createNetworkCalls.length, 1, 'delegates to the storefront service'); storefront.createNetworkCalls[0].onSuccess({ id: 'network_uuid', public_id: 'network_123' }); - assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.stores', 'network_123']]); + assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.index', 'network_123']]); }); }); diff --git a/tests/unit/routes/networks/index-test.js b/tests/unit/routes/networks/index-test.js index 9d05d470..dcc5a24f 100644 --- a/tests/unit/routes/networks/index-test.js +++ b/tests/unit/routes/networks/index-test.js @@ -1,17 +1,48 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; import Service from '@ember/service'; +import Evented from '@ember/object/evented'; module('Unit | Route | networks/index', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { // The engine's dummy app has no console host to provide the host router. - this.owner.register('service:hostRouter', class extends Service {}); + this.owner.register('service:hostRouter', class extends Service.extend(Evented) {}); + this.owner.register( + 'service:store', + class extends Service { + queries = []; + query = (modelName, params) => { + this.queries.push([modelName, params]); + return []; + }; + } + ); }); test('it exists', function (assert) { let route = this.owner.lookup('route:networks/index'); assert.ok(route); }); + + test('entering a network skips the networks list, and arriving on the list loads it', function (assert) { + const route = this.owner.lookup('route:networks/index'); + const store = this.owner.lookup('service:store'); + const hostRouter = this.owner.lookup('service:hostRouter'); + let refreshed = 0; + route.refresh = () => refreshed++; + + assert.deepEqual(route.model({}, { to: { name: 'console.storefront.networks.index.network.index' } }), []); + assert.strictEqual(store.queries.length, 0, 'no networks query on the way into a network'); + + hostRouter.trigger('routeDidChange', { to: { name: 'console.storefront.networks.index.network.stores' } }); + assert.strictEqual(refreshed, 0, 'moving inside the network leaves the list alone'); + + hostRouter.trigger('routeDidChange', { to: { name: 'console.storefront.networks.index.index' } }); + assert.strictEqual(refreshed, 1, 'the list loads once it is shown'); + + route.model({ page: 2 }, { to: { name: 'console.storefront.networks.index.index' } }); + assert.deepEqual(store.queries[0], ['network', { with_gateways: 1, with_notification_channels: 1, page: 2 }]); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 2938ed29..3e8f7b28 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -719,6 +719,12 @@ storefront: completed: Completed canceled: Canceled networks: + store-details: + back: Stores + revenue-share: "{share}% of network revenue" + through-network: Through the network + no-orders: No orders through the network yet. + no-customers: No customers through the network yet. card: stores-count: "{count, plural, =0 {No stores} one {# store} other {# stores}}" overview: @@ -730,7 +736,8 @@ storefront: manage-stores: Manage stores no-category: No category view-on-stores: View on the Stores page - open-store: Open this store's context + open-store: Manage this store + view-store: View store details online-count: "{count, plural, =0 {none online} one {# online} other {# online}}" invitations-hint: "pending, {declined} declined" orders-7d: Orders · 7 days From c595e6f75c9b0b9abab559effebc90ead1f10ea7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 18:58:50 +0800 Subject: [PATCH 131/133] fix(network orders): every order row is one line: the tracking number (order id on hover), store and driver as identity cells with a 20px image and the name only, the total without its tip line (tip on hover); a checkout's stores are evenly spaced 20px logos; the child branch follows the view, so Flat shows none --- .../storefront/network/orders/cell/driver.hbs | 11 +-- .../network/orders/cell/reference.hbs | 14 ++-- .../storefront/network/orders/cell/stores.hbs | 45 +++++------- .../storefront/network/orders/cell/stores.js | 12 +++- .../storefront/network/orders/cell/total.hbs | 15 ++-- .../storefront/network/orders/cell/total.js | 18 +++++ .../networks/index/network/orders.js | 11 ++- addon/styles/storefront-engine.css | 68 ++++++++----------- .../storefront/network/orders/cells-test.js | 51 +++++++++++--- 9 files changed, 143 insertions(+), 102 deletions(-) diff --git a/addon/components/storefront/network/orders/cell/driver.hbs b/addon/components/storefront/network/orders/cell/driver.hbs index 18595d37..f10ce121 100644 --- a/addon/components/storefront/network/orders/cell/driver.hbs +++ b/addon/components/storefront/network/orders/cell/driver.hbs @@ -1,9 +1,12 @@
{{#if this.driver}} - - {{#if (and @row.isGroup (gt @row.orderCount 1))}} - {{t "storefront.networks.orders.driver-shared" count=@row.orderCount}} - {{/if}} + {{! The identity cell: photo and name on one line; a shared driver says so on hover. }} + {{else if this.isPickup}} {{t "storefront.networks.orders.no-driver-needed"}} {{else if (gt this.driverCount 1)}} diff --git a/addon/components/storefront/network/orders/cell/reference.hbs b/addon/components/storefront/network/orders/cell/reference.hbs index e2cf4adf..09993c7b 100644 --- a/addon/components/storefront/network/orders/cell/reference.hbs +++ b/addon/components/storefront/network/orders/cell/reference.hbs @@ -1,4 +1,5 @@ -
+{{#let (if @column.isChild (@column.isChild @row) false) as |isChild|}} +
{{#if @row.isGroup}}
+{{/let}} diff --git a/addon/components/storefront/network/orders/cell/stores.hbs b/addon/components/storefront/network/orders/cell/stores.hbs index 8c86f6fd..dec2546a 100644 --- a/addon/components/storefront/network/orders/cell/stores.hbs +++ b/addon/components/storefront/network/orders/cell/stores.hbs @@ -1,33 +1,24 @@
- {{#each this.visible as |entry|}} - {{#if entry.store}} - {{#if this.isStack}} - {{! A checkout across stores: one round logo per store, the name and summary on hover. }} - - {{entry.name}} - - {{else}} - - {{/if}} - {{else if this.isStack}} - {{entry.initial}} - {{else}} - {{entry.name}} + {{#if this.isStack}} + {{! A checkout across stores: one round logo per store, its summary on hover. }} + {{#each this.visible as |entry|}} + + {{#if entry.store}} + + + {{else}} + {{entry.initial}} + {{/if}} + + {{/each}} + {{#if this.overflow}} + +{{this.overflow}} {{/if}} + {{else if this.single.store}} + + {{else if this.single}} + {{this.single.name}} {{else}} {{t "storefront.networks.orders.no-store"}} - {{/each}} - {{#if this.overflow}} - +{{this.overflow}} {{/if}}
diff --git a/addon/components/storefront/network/orders/cell/stores.js b/addon/components/storefront/network/orders/cell/stores.js index 3340d3d8..53837c5c 100644 --- a/addon/components/storefront/network/orders/cell/stores.js +++ b/addon/components/storefront/network/orders/cell/stores.js @@ -16,10 +16,15 @@ export default class StorefrontNetworkOrdersCellStoresComponent extends Componen return ids.map((id, index) => { const name = storesById[id]?.name ?? names[index] ?? names[0] ?? id; + const store = storesById[id] ?? null; + return { id, - store: storesById[id] ?? null, + store, name, + // An order placed on the network itself carries the network's id. + type: String(store?.public_id ?? id).startsWith('network_') ? 'network' : 'store', + logo: store?.logo_url ?? null, initial: String(name ?? '?') .charAt(0) .toUpperCase(), @@ -32,6 +37,11 @@ export default class StorefrontNetworkOrdersCellStoresComponent extends Componen return this.entries.length > 1; } + /** The one store behind a single order. */ + get single() { + return this.isStack ? null : (this.entries[0] ?? null); + } + get visible() { return this.entries.slice(0, MAX_VISIBLE); } diff --git a/addon/components/storefront/network/orders/cell/total.hbs b/addon/components/storefront/network/orders/cell/total.hbs index 7f44032e..e74fef97 100644 --- a/addon/components/storefront/network/orders/cell/total.hbs +++ b/addon/components/storefront/network/orders/cell/total.hbs @@ -1,14 +1,7 @@ -
+{{! One line for an order (its tip on hover); a checkout adds one line on how its tip splits. }} +
{{format-currency this.total this.currency}} - {{#if this.tip}} - - {{#if (eq this.tipLabel "split")}} - {{t "storefront.networks.orders.tip-split" tip=(format-currency this.tip this.currency) count=@row.orderCount}} - {{else if (eq this.tipLabel "shared")}} - {{t "storefront.networks.orders.tip-shared" tip=(format-currency this.tip this.currency)}} - {{else}} - {{t "storefront.networks.orders.tip-to-store" tip=(format-currency this.tip this.currency)}} - {{/if}} - + {{#if (and @row.isGroup this.tipText)}} + {{this.tipText}} {{/if}}
diff --git a/addon/components/storefront/network/orders/cell/total.js b/addon/components/storefront/network/orders/cell/total.js index b2798dc5..af7871b9 100644 --- a/addon/components/storefront/network/orders/cell/total.js +++ b/addon/components/storefront/network/orders/cell/total.js @@ -1,5 +1,7 @@ import Component from '@glimmer/component'; import { get } from '@ember/object'; +import { inject as service } from '@ember/service'; +import formatCurrency from '@fleetbase/ember-ui/utils/format-currency'; function number(value) { const parsed = parseFloat(value); @@ -8,6 +10,8 @@ function number(value) { } export default class StorefrontNetworkOrdersCellTotalComponent extends Component { + @service intl; + get total() { const { row } = this.args; @@ -39,4 +43,18 @@ export default class StorefrontNetworkOrdersCellTotalComponent extends Component return get(row, 'meta.checkout_id') ? 'shared' : 'store'; } + + /** The tip as a sentence: how a checkout splits it, or what an order's share is. */ + get tipText() { + const label = this.tipLabel; + + if (!label) { + return null; + } + + const tip = formatCurrency(this.tip, this.currency); + const keys = { split: 'tip-split', shared: 'tip-shared', network: 'tip-to-store', store: 'tip-to-store' }; + + return this.intl.t(`storefront.networks.orders.${keys[label]}`, { tip, count: this.args.row?.orderCount }); + } } diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index 9c56f173..a5e5ebc1 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -6,7 +6,7 @@ import { tracked, cached } from '@glimmer/tracking'; import { isBlank } from '@ember/utils'; import { timeout, task } from 'ember-concurrency'; import { action, get } from '@ember/object'; -import { groupOrdersByCheckout, STATUS_TABS } from '../../../../utils/order-groups'; +import { groupOrdersByCheckout, groupKeyOf, STATUS_TABS } from '../../../../utils/order-groups'; /** * Everything placed through the network, grouped by checkout: a multi-store purchase is one @@ -117,10 +117,16 @@ export default class NetworksIndexNetworkOrdersController extends BaseController const expanded = this.expandedGroups.has(row.id); row.expanded = expanded; - return expanded ? [row, ...row.orders.map((order) => Object.assign(order, { isChild: true, parentGroup: row.id }))] : [row]; + return expanded ? [row, ...row.orders] : [row]; }); } + /** + * An order sits under its checkout only while grouped and expanded. Read from tracked state + * at render, so switching to Flat redraws the row without the branch. + */ + isChildRow = (row) => Boolean(row && !row.isGroup && this.isGrouped && this.expandedGroups.has(groupKeyOf(row))); + @action toggleGroup(group) { const next = new Set(this.expandedGroups); @@ -170,6 +176,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController cellComponent: 'storefront/network/orders/cell/reference', onView: this.viewOrder, onToggle: this.toggleGroup, + isChild: this.isChildRow, width: '175px', resizable: true, sortable: false, diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index dfeb11d0..e86358d5 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -3926,67 +3926,57 @@ body[data-theme='dark'] .storefront-order-driver__muted { .storefront-identity-stack { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 0.25rem; + min-width: 0; } .storefront-identity-stack--avatars { flex-wrap: nowrap; - gap: 0; -} - -/* A multi-store row: overlapping round logos, the pill's text kept for screen readers only. */ -.storefront-identity-stack--avatars > * + * { - margin-left: -0.375rem; + gap: 0.25rem; } -.storefront-identity-stack__avatar.fleetbase-pill, +/* A multi-store row: one 20px round logo per store, the size of an identity cell's image, evenly spaced. */ .storefront-identity-stack__avatar { + display: inline-flex; flex: 0 0 auto; - gap: 0; - padding: 0; - border: 0; - background: transparent; -} - -.storefront-identity-stack__avatar .fleetbase-pill-body { - gap: 0; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + cursor: default; } -.storefront-identity-stack__avatar img { - width: 1.5rem; - height: 1.5rem; - object-fit: cover; +.storefront-identity-stack__logo { + width: 1.25rem; + height: 1.25rem; + border: 1px solid #e5e7eb; border-radius: 9999px; - box-shadow: 0 0 0 2px #fff; -} - -.storefront-identity-stack__avatar:hover { - position: relative; - z-index: 1; + object-fit: cover; + box-shadow: 0 1px 2px rgb(0 0 0 / 5%); } .storefront-identity-stack__initial { display: inline-flex; align-items: center; justify-content: center; - width: 1.5rem; - height: 1.5rem; + width: 1.25rem; + height: 1.25rem; + border: 1px solid #e5e7eb; border-radius: 9999px; - font-size: 0.6875rem; + font-size: 0.625rem; font-weight: 600; - background-color: #e5e7eb; + background-color: #f3f4f6; color: #374151; - box-shadow: 0 0 0 2px #fff; } -body[data-theme='dark'] .storefront-identity-stack__avatar img, +body[data-theme='dark'] .storefront-identity-stack__logo, body[data-theme='dark'] .storefront-identity-stack__initial { - box-shadow: 0 0 0 2px #111827; + border-color: #374151; } body[data-theme='dark'] .storefront-identity-stack__initial { - background-color: #374151; + background-color: #1f2937; color: #e5e7eb; } @@ -3998,7 +3988,6 @@ body[data-theme='dark'] .storefront-identity-stack__initial { } .storefront-identity-stack__overflow { - margin-left: 0.25rem; padding: 0 0.375rem; border-radius: 9999px; font-size: 0.6875rem; @@ -4038,8 +4027,8 @@ body[data-theme='dark'] .storefront-order-total__amount { .storefront-order-driver { display: flex; - flex-direction: column; - gap: 0.125rem; + align-items: center; + min-width: 0; } .storefront-order-actions { @@ -7734,8 +7723,11 @@ body[data-theme='dark'] .storefront-order-ref__toggle:hover { } .storefront-network-orders-table .storefront-order-total__tip { + overflow: hidden; font-size: 0.6875rem; - white-space: normal; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; line-height: 1.2; } diff --git a/tests/integration/components/storefront/network/orders/cells-test.js b/tests/integration/components/storefront/network/orders/cells-test.js index 836e3b87..83d91c55 100644 --- a/tests/integration/components/storefront/network/orders/cells-test.js +++ b/tests/integration/components/storefront/network/orders/cells-test.js @@ -4,12 +4,14 @@ import { setupIntl } from 'ember-intl/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { initialize } from '@fleetbase/storefront-engine/instance-initializers/register-storefront-resource-descriptors'; +import { initialize as initializeShared } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/network/orders cells', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks, 'en-us'); hooks.beforeEach(function () { + initializeShared(this.owner); initialize(this.owner); this.set('storesById', { store_a: { public_id: 'store_a', name: 'Rochor Noodle House', currency: 'SGD', logo_url: 'https://cdn.test/a.png' }, @@ -17,13 +19,13 @@ module('Integration | Component | storefront/network/orders cells', function (ho }); }); - test('one store reads as a single-line pill, without the currency underneath', async function (assert) { + test('one store reads as a single-line identity cell, without the currency underneath', async function (assert) { this.set('row', { meta: { storefront_id: 'store_a', storefront: 'Rochor Noodle House' } }); await render(hbs``); - assert.dom('[data-test-resource-pill-title]').hasText('Rochor Noodle House'); - assert.dom('[data-test-resource-pill-subtitle]').doesNotExist(); + assert.dom('[data-test-order-store-identity] [data-test-identity-label]').hasText('Rochor Noodle House'); + assert.dom('[data-test-order-store-identity] [data-test-resource-identity-image]').exists(); assert.dom('[data-test-order-store-avatar]').doesNotExist(); }); @@ -33,21 +35,48 @@ module('Integration | Component | storefront/network/orders cells', function (ho await render(hbs``); assert.dom('[data-test-order-stores]').hasClass('storefront-identity-stack--avatars'); - assert.dom('[data-test-order-store-avatar]').exists({ count: 2 }); + assert.dom('[data-test-order-store-avatar]').exists({ count: 3 }, 'one evenly spaced slot per store'); assert.dom('[data-test-order-store-avatar]').hasAttribute('aria-label', 'Rochor Noodle House'); - assert.dom('[data-test-pill-online-indicator]').doesNotExist(); + assert.dom('[data-test-order-store-avatar] img').exists({ count: 2 }, 'the loaded stores show their logo'); assert.dom('.storefront-identity-stack__initial').hasText('U', 'a store that is not loaded falls back to its initial'); assert.dom('.storefront-identity-stack__initial').hasAttribute('title', 'Unknown Mart'); }); - test('an order reference shows its item count, not the checkout it belongs to', async function (assert) { - this.set('row', { public_id: 'order_1', item_count: 2, meta: { checkout_id: 'chkt_123' } }); - this.set('column', { onView() {}, onToggle() {} }); + test('an order reference is one line: the tracking number, with the order id on hover', async function (assert) { + this.set('row', { public_id: 'order_1', tracking: 'FLE123SG', item_count: 2, meta: { checkout_id: 'chkt_123' } }); + this.set('column', { onView() {}, onToggle() {}, isChild: () => false }); + + await render(hbs``); + + assert.dom('[data-test-order-tracking]').hasText('FLE123SG'); + assert.dom('[data-test-order-tracking]').hasAttribute('title', 'order_1'); + assert.dom('.storefront-order-ref__meta').doesNotExist('no item count or checkout under an order'); + assert.dom('.storefront-order-ref__branch').doesNotExist(); + }); + + test('an order under an expanded checkout draws the branch', async function (assert) { + this.set('row', { public_id: 'order_1', tracking: 'FLE123SG' }); + this.set('column', { onView() {}, onToggle() {}, isChild: () => true }); await render(hbs``); - assert.dom('[data-test-order-reference]').includesText('order_1'); - assert.dom('[data-test-order-reference]').doesNotIncludeText('chkt_123'); - assert.dom('.storefront-order-ref__meta').hasText('2 items'); + assert.dom('[data-test-order-reference]').hasClass('is-child'); + assert.dom('.storefront-order-ref__branch').exists(); + }); + + test('a single store and a driver are identity cells: a 20px image and one line of text', async function (assert) { + this.set('row', { meta: { storefront_id: 'store_a', storefront: 'Rochor Noodle House' } }); + this.set('driverColumn', {}); + this.set('driverRow', { driver_assigned: { resourceType: 'driver', name: 'Ava Driver', phone: '+6590000000', photo_url: 'https://cdn.test/d.png' } }); + + await render(hbs` + + + `); + + assert.dom('[data-test-order-store-identity] [data-test-identity-label]').hasText('Rochor Noodle House'); + assert.dom('[data-test-order-driver-identity] [data-test-identity-label]').hasText('Ava Driver'); + assert.dom('[data-test-order-driver]').doesNotIncludeText('+6590000000', 'no phone under the name'); + assert.dom('[data-test-resource-pill-subtitle]').doesNotExist(); }); }); From fed8eef93182b168d0afac9258496135f3765980 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 19:18:59 +0800 Subject: [PATCH 132/133] fix(trucks): saved catalogs stay on the truck: the sync reloads the relation so the save answers with the new catalogs, and clears the cached truck lists since only the pivot changed; the card title opens the truck instead of the Fleet-Ops vehicle panel --- addon/components/storefront/truck/card.hbs | 12 +++- addon/templates/food-trucks/index.hbs | 2 +- .../networks/index/network/trucks.hbs | 2 +- server/src/Models/FoodTruck.php | 29 ++++++---- .../Unit/Models/FoodTruckCatalogsTest.php | 56 +++++++++++++++++++ 5 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 server/tests/Unit/Models/FoodTruckCatalogsTest.php diff --git a/addon/components/storefront/truck/card.hbs b/addon/components/storefront/truck/card.hbs index 99f91f87..7955b370 100644 --- a/addon/components/storefront/truck/card.hbs +++ b/addon/components/storefront/truck/card.hbs @@ -3,7 +3,17 @@ - + {{! The title opens the truck; the vehicle opens from the menu (Open vehicle) or its summary. }} + diff --git a/addon/templates/food-trucks/index.hbs b/addon/templates/food-trucks/index.hbs index 5259601c..51982700 100644 --- a/addon/templates/food-trucks/index.hbs +++ b/addon/templates/food-trucks/index.hbs @@ -29,7 +29,7 @@ class="h-full" as |Item| > - + {{/if}} diff --git a/addon/templates/networks/index/network/trucks.hbs b/addon/templates/networks/index/network/trucks.hbs index 729cc553..02fcb354 100644 --- a/addon/templates/networks/index/network/trucks.hbs +++ b/addon/templates/networks/index/network/trucks.hbs @@ -29,7 +29,7 @@ class="h-full" as |Item| > - + {{/if}} diff --git a/server/src/Models/FoodTruck.php b/server/src/Models/FoodTruck.php index 97a572c5..9a65909e 100644 --- a/server/src/Models/FoodTruck.php +++ b/server/src/Models/FoodTruck.php @@ -182,19 +182,19 @@ public function setCatalogs(array $catalogs = []): FoodTruck ->values(); // Remove pivot rows for catalogs not in incoming list - $existingCatalogs - ->whereNotIn('uuid', $incomingUuids) - ->each(function (Catalog $catalog) { - CatalogSubject::where([ - 'catalog_uuid' => $catalog->uuid, - 'subject_uuid' => $this->uuid, - 'subject_type' => get_class($this), - ])->delete(); - }); + $removed = $existingCatalogs->whereNotIn('uuid', $incomingUuids); + $removed->each(function (Catalog $catalog) { + CatalogSubject::where([ + 'catalog_uuid' => $catalog->uuid, + 'subject_uuid' => $this->uuid, + 'subject_type' => get_class($this), + ])->delete(); + }); // Create or restore pivot rows for each incoming + $added = 0; foreach ($incomingUuids as $catalogUuid) { - CatalogSubject::firstOrCreate( + $subject = CatalogSubject::firstOrCreate( [ 'catalog_uuid' => $catalogUuid, 'subject_uuid' => $this->uuid, @@ -205,6 +205,15 @@ public function setCatalogs(array $catalogs = []): FoodTruck 'created_by_uuid' => session('user'), ] ); + $added += $subject->wasRecentlyCreated ? 1 : 0; + } + + // The relation was loaded before the sync; reload it so the saved truck answers with its new catalogs. + $this->load('catalogs'); + + // Only the pivot changed, so the truck fires no `updated` event; clear the cached truck lists here. + if (($removed->isNotEmpty() || $added > 0) && method_exists($this, 'invalidateApiCache')) { + $this->invalidateApiCache(); } return $this; diff --git a/server/tests/Unit/Models/FoodTruckCatalogsTest.php b/server/tests/Unit/Models/FoodTruckCatalogsTest.php new file mode 100644 index 00000000..77ecc28a --- /dev/null +++ b/server/tests/Unit/Models/FoodTruckCatalogsTest.php @@ -0,0 +1,56 @@ +connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['catalogs', 'catalog_subjects'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('catalogs', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('catalog_subjects', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('catalog_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('catalogs')->insert([ + ['uuid' => '70000000-0000-4000-8000-000000000001', 'public_id' => 'catalog_breakfast', 'name' => 'Breakfast'], + ['uuid' => '70000000-0000-4000-8000-000000000002', 'public_id' => 'catalog_lunch', 'name' => 'Lunch'], + ]); +} + +test('syncing a truck\'s catalogs answers with the catalogs it now has, not the ones loaded before', function () { + createFoodTruckCatalogSchema(); + + $truck = new FoodTruck(); + $truck->forceFill(['uuid' => '60000000-0000-4000-8000-000000000001', 'company_uuid' => 'company_uuid']); + $truck->exists = true; + + $truck->setCatalogs(['70000000-0000-4000-8000-000000000001']); + expect($truck->catalogs->pluck('name')->all())->toBe(['Breakfast']); + + // The relation is loaded now; adding a catalog must not answer with the stale list. + $truck->setCatalogs([['uuid' => '70000000-0000-4000-8000-000000000001'], ['uuid' => '70000000-0000-4000-8000-000000000002']]); + expect($truck->catalogs->pluck('name')->sort()->values()->all())->toBe(['Breakfast', 'Lunch']); + + $truck->setCatalogs(['70000000-0000-4000-8000-000000000002']); + expect($truck->catalogs->pluck('name')->all())->toBe(['Lunch']); +}); From f5c7df9bb0f9539d31e7d4edb09bec202fdb6a3a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 19:36:42 +0800 Subject: [PATCH 133/133] feat(incoming order): the modal reads as a ticket: store, total and payment up top, the tracking number with status, fulfillment and timing, the route as stops, customer and driver side by side, the customer's note, the items and totals the order panel uses (now shared as line items, zero tips left out), and the label codes for handover; Accept is green; the truck card opens the truck without a page handler crashing it --- addon/components/modals/incoming-order.hbs | 344 ++++++------------ addon/components/modals/incoming-order.js | 98 +++++ .../order/details/commerce-summary.hbs | 72 +--- .../order/details/commerce-summary.js | 27 +- .../storefront/order/line-items.hbs | 72 ++++ .../components/storefront/order/line-items.js | 45 +++ addon/components/storefront/truck/card.hbs | 4 +- addon/components/storefront/truck/card.js | 5 + addon/services/storefront-order-actions.js | 2 +- addon/services/storefront.js | 3 +- addon/styles/storefront-engine.css | 303 +++++++++++++++ app/components/storefront/order/line-items.js | 1 + .../components/modals/incoming-order-test.js | 65 +++- .../storefront/order/line-items-test.js | 26 ++ .../components/storefront/truck/card-test.js | 14 +- translations/en-us.yaml | 9 + 16 files changed, 750 insertions(+), 340 deletions(-) create mode 100644 addon/components/modals/incoming-order.js create mode 100644 addon/components/storefront/order/line-items.hbs create mode 100644 addon/components/storefront/order/line-items.js create mode 100644 app/components/storefront/order/line-items.js create mode 100644 tests/integration/components/storefront/order/line-items-test.js diff --git a/addon/components/modals/incoming-order.hbs b/addon/components/modals/incoming-order.hbs index f06378d8..4a14c872 100644 --- a/addon/components/modals/incoming-order.hbs +++ b/addon/components/modals/incoming-order.hbs @@ -1,246 +1,128 @@ -
-
-
-
- {{@options.order.public_id}} -
-
-

{{or @options.order.tracking @options.order.public_id}}

- {{#if @options.order.tracking}}
{{@options.order.public_id}}
{{/if}} -
-
- -
-
-
-
-
-
-
{{t "storefront.common.store"}}
-
- {{@options.store.name}} -
-
{{@options.store.name}}
- -
+
+ {{! The ticket: the store it came in at, when, and what it came to. }} +
+
+ +
+
{{n-a this.store.name}}
+
+ {{t "storefront.component.modals.incoming-order.placed" ago=(n-a this.order.createdAgo)}} + · {{t "storefront.component.modals.incoming-order.items-count" count=this.itemCount}}
-
-
- {{#unless @options.order.meta.is_pickup}} -
-
-
{{t "storefront.component.modals.incoming-order.assigned"}}
-
- {{#if @options.order.driver_assigned.id}} - - {{else}} -
-
{{t "storefront.component.modals.incoming-order.not-assigned"}}
-
- {{/if}} - {{#if @options.assignDriver}} -
-
- {{/if}} -
-
-
- {{/unless}} -
-
-
{{t "storefront.common.customer"}}
-
- -
{{n-a @options.order.customer.email}}
-
{{n-a @options.order.customer.phone}}
- {{#unless @options.order.meta.is_pickup}} -
-
{{t "storefront.component.modals.incoming-order.address"}}
-
-
- -
-
- -
-
-
- {{/unless}} -
-
-
+
+
{{format-currency this.total this.currency}}
+
+ {{#if this.isCash}} + + {{t "storefront.common.cash"}} + {{else}} + + {{n-a (titleize this.paymentLabel) (t "storefront.component.modals.incoming-order.paid-in-app")}} + {{/if}}
-
-
- {{#if @options.order.meta.is_pickup}} -
{{t "storefront.common.pickup"}}
+
+ +
+
+
{{or this.order.tracking this.order.public_id}}
+ {{#if this.order.tracking}}
{{this.order.public_id}}
{{/if}} +
+
+ + + + + {{#if this.order.scheduled_at}} + {{t "storefront.component.modals.incoming-order.scheduled-for" date=(format-date-fns this.order.scheduled_at "d MMM, HH:mm")}} {{else}} -
{{t "storefront.common.delivery"}}
+ {{t "storefront.order.asap"}} {{/if}} -
- {{#if @options.order.meta.is_pickup}} -
- {{t "storefront.component.modals.incoming-order.pickup-order"}} -
-
- -
- -
-
- {{else if @options.order.payload.hasWaypoints}} - - {{else}} -
-
-
-
- -
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
- {{/if}} -
-
+
-
-
-
-
{{t "storefront.component.modals.incoming-order.tracking"}}
-
({{@options.order.tracking}})
-
-
-
- {{@options.order.public_id}} -
-
- {{@options.order.public_id}} +
+ + {{! Where it goes: from the store location to the customer, or the counter it is collected from. }} +
+
{{if this.isPickup (t "storefront.common.pickup") (t "storefront.common.delivery")}}
+
    +
  1. + +
    +
    {{if this.isPickup (t "storefront.component.modals.incoming-order.collects-from") (t "storefront.component.modals.incoming-order.leaves-from")}}
    +
    + {{#if this.pickup.name}}{{this.pickup.name}} · {{/if}}{{n-a this.pickup.address}}
    -
-
-
-
-
-
{{t "storefront.component.modals.incoming-order.summary"}}
- {{#if @options.order.payload.cod_amount}} -
- - {{t "storefront.common.cash"}} -
- {{/if}} -
-
-
- {{#each @options.order.payload.entities as |entity|}} -
-
-
- {{entity.meta.quantity}}x -
-
-
-
- {{entity.name}} -
-
-

{{entity.name}}

-
-

{{entity.description}}

-
-
- {{#each entity.meta.variants as |variant|}} -
- {{variant.name}} -
- {{/each}} -
-
- {{#each entity.meta.addons as |addon|}} -
- + {{addon.name}} -
- {{/each}} -
-
-
-
- {{format-currency entity.meta.subtotal entity.currency}} -
-
- {{/each}} -
-
-
- {{t "storefront.component.modals.incoming-order.subtotal"}} - {{format-currency @options.order.meta.subtotal @options.order.meta.currency}} -
- {{#unless @options.order.meta.is_pickup}} -
- {{t "storefront.component.modals.incoming-order.fee"}} - {{format-currency @options.order.meta.delivery_fee @options.order.meta.currency}} -
- {{/unless}} - {{#if @options.order.meta.tip}} -
- {{t "storefront.component.modals.incoming-order.tip"}} - {{get-tip-amount @options.order.meta.tip @options.order.meta.subtotal @options.order.meta.currency}} -
- {{/if}} - {{#if @options.order.meta.delivery_tip}} -
- {{t "storefront.component.modals.incoming-order.delivery-tip"}} - {{get-tip-amount - @options.order.meta.delivery_tip - @options.order.meta.subtotal - @options.order.meta.currency - }} -
- {{/if}} -
-
-
- {{t "storefront.common.total"}} - {{format-currency @options.order.meta.total @options.order.meta.currency}} + + {{#unless this.isPickup}} +
  • + +
    +
    {{t "storefront.component.modals.incoming-order.deliver-to"}}
    +
    + {{#if this.dropoff.name}}{{this.dropoff.name}} · {{/if}}{{n-a this.dropoff.address}}
    -
  • -
    + + {{/unless}} + + + + {{! Who it is for, and who takes it there. }} +
    +
    +
    {{t "storefront.common.customer"}}
    + + {{#if this.customer.phone}}
    {{this.customer.phone}}
    {{/if}} + {{#if this.customer.email}}
    {{this.customer.email}}
    {{/if}}
    -
    +
    +
    {{t "storefront.common.driver"}}
    + {{#if this.isPickup}} +
    {{t "storefront.networks.orders.no-driver-needed"}}
    + {{else if this.driver}} + + {{else}} +
    {{t "storefront.component.modals.incoming-order.not-assigned"}}
    + {{/if}} + {{#if (and @options.assignDriver (not this.isPickup))}} +
    + + + {{#if this.order.notes}} +
    +
    {{t "storefront.component.modals.incoming-order.note"}}
    +

    {{this.order.notes}}

    +
    + {{/if}} + + {{! What is in it. }} +
    +
    {{t "storefront.component.modals.incoming-order.summary"}}
    + +
    + + {{! The label codes, for printing or scanning at handover. }} + {{#if (or this.qrCode this.barcode)}} +
    + {{#if this.qrCode}}{{t{{/if}} + {{#if this.barcode}}{{t{{/if}} +
    {{t "storefront.component.modals.incoming-order.scan-hint"}}
    +
    + {{/if}}
    - \ No newline at end of file + diff --git a/addon/components/modals/incoming-order.js b/addon/components/modals/incoming-order.js new file mode 100644 index 00000000..416b43c9 --- /dev/null +++ b/addon/components/modals/incoming-order.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import { get } from '@ember/object'; + +function number(value) { + const parsed = parseFloat(value); + + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * The incoming order, read as a ticket: who placed it at which store, where it goes, what is in + * it and what it came to, with accept and decline in the modal footer. + */ +export default class ModalsIncomingOrderComponent extends Component { + get order() { + return this.args.options?.order; + } + + get store() { + return this.args.options?.store; + } + + get meta() { + return this.order?.meta ?? {}; + } + + get isPickup() { + return Boolean(this.meta.is_pickup); + } + + get currency() { + return this.meta.currency ?? this.store?.currency; + } + + get total() { + return number(this.meta.total ?? this.order?.transaction_amount); + } + + get itemCount() { + const entities = get(this.order ?? {}, 'payload.entities'); + const list = entities?.toArray?.() ?? Array.from(entities ?? []); + + return list.reduce((sum, entity) => sum + (number(get(entity, 'meta.quantity')) || 1), 0); + } + + get isCash() { + return Boolean(get(this.order ?? {}, 'payload.cod_amount')) || this.meta.gateway === 'cash'; + } + + get paymentLabel() { + return this.isCash ? null : (this.meta.gateway ?? null); + } + + /** A place on the route as one line: its name, then its address when the name is not the address. */ + placeLine(place) { + if (!place) { + return { name: null, address: null }; + } + + const name = get(place, 'name'); + const address = get(place, 'address') ?? get(place, 'street1'); + + // Leave the name out when the address already starts with it ("T2" and "T2 - 26 Peace Avenue"). + const repeats = name && address && String(address).toLowerCase().startsWith(String(name).toLowerCase()); + + return { name: name && !repeats ? name : null, address: address ?? name ?? null }; + } + + get pickup() { + return this.placeLine(get(this.order ?? {}, 'payload.pickup')); + } + + get dropoff() { + return this.placeLine(get(this.order ?? {}, 'payload.dropoff')); + } + + get driver() { + const driver = get(this.order ?? {}, 'driver_assigned'); + + return get(driver ?? {}, 'id') ? driver : null; + } + + get customer() { + return get(this.order ?? {}, 'customer'); + } + + get qrCode() { + const code = get(this.order ?? {}, 'tracking_number.qr_code'); + + return code ? `data:image/png;base64,${code}` : null; + } + + get barcode() { + const code = get(this.order ?? {}, 'tracking_number.barcode'); + + return code ? `data:image/png;base64,${code}` : null; + } +} diff --git a/addon/components/storefront/order/details/commerce-summary.hbs b/addon/components/storefront/order/details/commerce-summary.hbs index a2bce9cd..9d51106e 100644 --- a/addon/components/storefront/order/details/commerce-summary.hbs +++ b/addon/components/storefront/order/details/commerce-summary.hbs @@ -8,75 +8,5 @@ @wrapperClass="bordered-top" ...attributes > -
    - {{#if @resource.payload.entities}} -
    - {{#each @resource.payload.entities as |entity|}} -
    -
    {{n-a entity.meta.quantity 1}}x
    - {{entity.name}} -
    -
    {{n-a entity.name (t "storefront.order.sections.unnamed-item")}}
    -
    {{n-a entity.description (t "storefront.order.sections.no-description")}}
    - {{#if entity.meta.scheduled_at}} -
    - {{t "storefront.order.sections.scheduled-at" date=(format-date-fns entity.meta.scheduled_at "d MMM yyyy HH:mm")}} -
    - {{/if}} - {{#if entity.meta.variants}} -
    - {{#each entity.meta.variants as |variant|}} - {{or variant.name variant.label variant.option}} - {{/each}} -
    - {{/if}} - {{#if entity.meta.addons}} -
    - {{#each entity.meta.addons as |addon|}} - + {{or addon.name addon.label}} - {{/each}} -
    - {{/if}} -
    -
    - {{format-currency entity.meta.subtotal (or entity.currency @resource.meta.currency)}} -
    -
    - {{/each}} -
    - {{else}} -
    - - {{t "storefront.order.sections.no-items"}} -
    - {{/if}} - -
    -
    {{t "storefront.order.sections.subtotal"}}{{format-currency @resource.meta.subtotal @resource.meta.currency}}
    - {{#unless @resource.meta.is_pickup}} -
    {{t "storefront.orders.create.delivery-fee"}}{{format-currency @resource.meta.delivery_fee @resource.meta.currency}}
    - {{/unless}} - {{#each this.promotions as |promotion|}} -
    - - {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} - - -{{format-currency promotion.amount @resource.meta.currency}} -
    - {{/each}} - {{#if this.unattributedDiscount}} -
    {{t "storefront.order.sections.discount"}}-{{format-currency this.unattributedDiscount @resource.meta.currency}}
    - {{/if}} - {{#if @resource.meta.tip}} -
    {{t "storefront.order.sections.tip"}}{{get-tip-amount @resource.meta.tip @resource.meta.subtotal @resource.meta.currency}}
    - {{/if}} - {{#if @resource.meta.delivery_tip}} -
    {{t "storefront.order.sections.delivery-tip"}}{{get-tip-amount @resource.meta.delivery_tip @resource.meta.subtotal @resource.meta.currency}}
    - {{/if}} -
    - {{t "storefront.order.sections.total"}} - {{format-currency (or @resource.meta.total @resource.transaction_amount 0) @resource.meta.currency}} -
    -
    -
    + diff --git a/addon/components/storefront/order/details/commerce-summary.js b/addon/components/storefront/order/details/commerce-summary.js index 9e1b1419..e567a8a2 100644 --- a/addon/components/storefront/order/details/commerce-summary.js +++ b/addon/components/storefront/order/details/commerce-summary.js @@ -1,25 +1,4 @@ -import Component from '@glimmer/component'; +import templateOnlyComponent from '@ember/component/template-only'; -export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component { - /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ - get promotions() { - const promotions = this.args.resource?.meta?.promotions; - if (!Array.isArray(promotions)) { - return []; - } - - return promotions.map((promotion) => ({ - name: promotion.name || promotion.code || 'Promotion', - code: promotion.code, - isFreeDelivery: promotion.type === 'free_delivery', - amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), - })); - } - - /** The order's discount when no promotion breakdown was recorded. */ - get unattributedDiscount() { - const discount = Number(this.args.resource?.meta?.discount) || 0; - - return this.promotions.length === 0 && discount > 0 ? discount : 0; - } -} +/** The order section of the panel: its items and totals, as the line items component draws them. */ +export default templateOnlyComponent(); diff --git a/addon/components/storefront/order/line-items.hbs b/addon/components/storefront/order/line-items.hbs new file mode 100644 index 00000000..e797a462 --- /dev/null +++ b/addon/components/storefront/order/line-items.hbs @@ -0,0 +1,72 @@ +{{! What was bought and what it came to: one row per item (quantity, photo, name, options, price), then the totals. }} +
    + {{#if this.order.payload.entities}} +
    + {{#each this.order.payload.entities as |entity|}} +
    +
    {{n-a entity.meta.quantity 1}}x
    + {{entity.name}} +
    +
    {{n-a entity.name (t "storefront.order.sections.unnamed-item")}}
    +
    {{n-a entity.description (t "storefront.order.sections.no-description")}}
    + {{#if entity.meta.scheduled_at}} +
    + {{t "storefront.order.sections.scheduled-at" date=(format-date-fns entity.meta.scheduled_at "d MMM yyyy HH:mm")}} +
    + {{/if}} + {{#if entity.meta.variants}} +
    + {{#each entity.meta.variants as |variant|}} + {{or variant.name variant.label variant.option}} + {{/each}} +
    + {{/if}} + {{#if entity.meta.addons}} +
    + {{#each entity.meta.addons as |addon|}} + + {{or addon.name addon.label}} + {{/each}} +
    + {{/if}} +
    +
    + {{format-currency entity.meta.subtotal (or entity.currency this.order.meta.currency)}} +
    +
    + {{/each}} +
    + {{else}} +
    + + {{t "storefront.order.sections.no-items"}} +
    + {{/if}} + +
    +
    {{t "storefront.order.sections.subtotal"}}{{format-currency this.order.meta.subtotal this.order.meta.currency}}
    + {{#unless this.order.meta.is_pickup}} +
    {{t "storefront.orders.create.delivery-fee"}}{{format-currency this.order.meta.delivery_fee this.order.meta.currency}}
    + {{/unless}} + {{#each this.promotions as |promotion|}} +
    + + {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} + + -{{format-currency promotion.amount this.order.meta.currency}} +
    + {{/each}} + {{#if this.unattributedDiscount}} +
    {{t "storefront.order.sections.discount"}}-{{format-currency this.unattributedDiscount this.order.meta.currency}}
    + {{/if}} + {{#if this.hasTip}} +
    {{t "storefront.order.sections.tip"}}{{get-tip-amount this.order.meta.tip this.order.meta.subtotal this.order.meta.currency}}
    + {{/if}} + {{#if this.hasDeliveryTip}} +
    {{t "storefront.order.sections.delivery-tip"}}{{get-tip-amount this.order.meta.delivery_tip this.order.meta.subtotal this.order.meta.currency}}
    + {{/if}} +
    + {{t "storefront.order.sections.total"}} + {{format-currency (or this.order.meta.total this.order.transaction_amount 0) this.order.meta.currency}} +
    +
    +
    diff --git a/addon/components/storefront/order/line-items.js b/addon/components/storefront/order/line-items.js new file mode 100644 index 00000000..b1080da8 --- /dev/null +++ b/addon/components/storefront/order/line-items.js @@ -0,0 +1,45 @@ +import Component from '@glimmer/component'; + +function isAboveZero(value) { + return (parseFloat(String(value ?? '').replace('%', '')) || 0) > 0; +} + +/** + * An order's items and totals, shared by the order panel and the incoming order modal. + */ +export default class StorefrontOrderLineItemsComponent extends Component { + get order() { + return this.args.order ?? this.args.resource; + } + + /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ + get promotions() { + const promotions = this.order?.meta?.promotions; + if (!Array.isArray(promotions)) { + return []; + } + + return promotions.map((promotion) => ({ + name: promotion.name || promotion.code || 'Promotion', + code: promotion.code, + isFreeDelivery: promotion.type === 'free_delivery', + amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), + })); + } + + /** The order's discount when no promotion breakdown was recorded. */ + get unattributedDiscount() { + const discount = Number(this.order?.meta?.discount) || 0; + + return this.promotions.length === 0 && discount > 0 ? discount : 0; + } + + /** A tip line shows only when there is a tip: an amount or a percentage above zero. */ + get hasTip() { + return isAboveZero(this.order?.meta?.tip); + } + + get hasDeliveryTip() { + return isAboveZero(this.order?.meta?.delivery_tip); + } +} diff --git a/addon/components/storefront/truck/card.hbs b/addon/components/storefront/truck/card.hbs index 7955b370..11ab5b4e 100644 --- a/addon/components/storefront/truck/card.hbs +++ b/addon/components/storefront/truck/card.hbs @@ -9,8 +9,8 @@ @titleFallback={{t "storefront.trucks.card.no-vehicle"}} @subtitle={{or this.driverName (t "storefront.trucks.card.no-driver")}} @showOnlineIndicator={{true}} - @static={{not (or @onOpen this.vehicle.id)}} - @onClick={{if @onOpen (fn @onOpen this.truck)}} + @static={{not (or this.openTruck this.vehicle.id)}} + @onClick={{this.openTruck}} class="storefront-card__identity" data-test-truck-vehicle /> diff --git a/addon/components/storefront/truck/card.js b/addon/components/storefront/truck/card.js index 7ce1bf48..a100bfc8 100644 --- a/addon/components/storefront/truck/card.js +++ b/addon/components/storefront/truck/card.js @@ -10,6 +10,11 @@ import { catalogServingNow } from './panel'; * catalogs it carries. */ export default class StorefrontTruckCardComponent extends Component { + /** Opens the truck from its title, when the page gives a way to. */ + get openTruck() { + return typeof this.args.onOpen === 'function' ? () => this.args.onOpen(this.truck) : null; + } + get truck() { return this.args.truck ?? this.args.foodTruck ?? this.args.resource; } diff --git a/addon/services/storefront-order-actions.js b/addon/services/storefront-order-actions.js index e32b3f40..896e7a61 100644 --- a/addon/services/storefront-order-actions.js +++ b/addon/services/storefront-order-actions.js @@ -393,7 +393,7 @@ export default class StorefrontOrderActionsService extends Service { acceptButtonText: this.intl.t('storefront.component.widget.orders.accept-order'), acceptButtonScheme: 'success', acceptButtonIcon: 'check', - modalClass: 'scrollable-height-dialog', + modalClass: 'scrollable-height-dialog storefront-incoming-order-modal', order, store, assignDriver: async () => { diff --git a/addon/services/storefront.js b/addon/services/storefront.js index e134c4f0..859aeb6b 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -232,6 +232,7 @@ export default class StorefrontService extends Service.extend(Evented) { this.modalsManager.show('modals/incoming-order', { title: this.intl.t('storefront.service.storefront.new-incoming-order'), acceptButtonText: alreadyAccepted ? this.intl.t('common.ok') : this.intl.t('storefront.service.storefront.accept-order'), + acceptButtonScheme: alreadyAccepted ? 'primary' : 'success', acceptButtonIcon: 'check', acceptButtonIconPrefix: 'fas', hideDeclineButton: alreadyAccepted, @@ -239,7 +240,7 @@ export default class StorefrontService extends Service.extend(Evented) { declineButtonScheme: 'danger', closeButton: false, backdropClose: false, - modalClass: 'scrollable-height-dialog', + modalClass: 'scrollable-height-dialog storefront-incoming-order-modal', order, store, confirm: async (modal) => { diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index e86358d5..dcc8f4ac 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -10457,3 +10457,306 @@ body[data-theme='dark'] .storefront-product-translations__fields { body[data-theme='dark'] .storefront-product-translations__key { color: #9ca3af; } + +/* Incoming order modal (`storefront-incoming-order-modal`): a ticket read top to bottom (store and total, reference, route, people, items, label codes) */ +.storefront-incoming-order { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 0.25rem 1.25rem 1.25rem; + font-size: 0.8125rem; + color: #374151; +} + +body[data-theme='dark'] .storefront-incoming-order { + color: #d1d5db; +} + +.storefront-incoming-order__hero, +.storefront-incoming-order__ref { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-width: 0; +} + +.storefront-incoming-order__store { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.storefront-incoming-order__logo { + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + object-fit: cover; +} + +body[data-theme='dark'] .storefront-incoming-order__logo { + border-color: #374151; +} + +.storefront-incoming-order__store-name { + overflow: hidden; + font-size: 0.9375rem; + font-weight: 600; + color: #111827; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-incoming-order__store-name, +body[data-theme='dark'] .storefront-incoming-order__tracking, +body[data-theme='dark'] .storefront-incoming-order__total { + color: #f9fafb; +} + +.storefront-incoming-order__amount { + flex: 0 0 auto; + text-align: right; +} + +.storefront-incoming-order__total { + font-size: 1.25rem; + font-weight: 700; + line-height: 1.2; + color: #111827; +} + +.storefront-incoming-order__muted { + font-size: 0.75rem; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__muted { + color: #9ca3af; +} + +.storefront-incoming-order__ref { + padding: 0.625rem 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + background-color: #f9fafb; +} + +body[data-theme='dark'] .storefront-incoming-order__ref { + border-color: #374151; + background-color: #1f2937; +} + +.storefront-incoming-order__tracking { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9375rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #111827; +} + +.storefront-incoming-order__chips { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.375rem; +} + +.storefront-incoming-order__chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.25rem; + background-color: #f3f4f6; + color: #374151; +} + +body[data-theme='dark'] .storefront-incoming-order__chip { + background-color: #374151; + color: #e5e7eb; +} + +.storefront-incoming-order__section { + padding-top: 0.875rem; + border-top: 1px solid #e5e7eb; +} + +body[data-theme='dark'] .storefront-incoming-order__section { + border-color: #374151; +} + +.storefront-incoming-order__label { + margin-bottom: 0.5rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__label { + color: #9ca3af; +} + +.storefront-incoming-order__route { + position: relative; + display: flex; + flex-direction: column; + gap: 0.625rem; + margin: 0; + padding: 0; + list-style: none; +} + +/* The line between the two stops */ +.storefront-incoming-order__route::before { + position: absolute; + top: 1.25rem; + bottom: 1.25rem; + left: 0.6875rem; + width: 1px; + background-color: #d1d5db; + content: ''; +} + +body[data-theme='dark'] .storefront-incoming-order__route::before { + background-color: #4b5563; +} + +.storefront-incoming-order__stop { + position: relative; + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.storefront-incoming-order__stop-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.375rem; + height: 1.375rem; + flex: 0 0 auto; + border-radius: 9999px; + background-color: #dbeafe; + color: #1d4ed8; +} + +.storefront-incoming-order__stop-icon--dropoff { + background-color: #fee2e2; + color: #b91c1c; +} + +body[data-theme='dark'] .storefront-incoming-order__stop-icon { + background-color: rgb(30 58 138 / 50%); + color: #93c5fd; +} + +body[data-theme='dark'] .storefront-incoming-order__stop-icon--dropoff { + background-color: rgb(127 29 29 / 50%); + color: #fca5a5; +} + +.storefront-incoming-order__stop-label { + font-size: 0.6875rem; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__stop-label { + color: #9ca3af; +} + +.storefront-incoming-order__line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-incoming-order__line strong { + font-weight: 600; + color: #111827; +} + +body[data-theme='dark'] .storefront-incoming-order__line strong { + color: #f3f4f6; +} + +.storefront-incoming-order__people { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.storefront-incoming-order__contact { + display: flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.375rem; + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__contact { + color: #9ca3af; +} + +.storefront-incoming-order__note { + margin: 0; + padding: 0.5rem 0.75rem; + border-left: 3px solid #f59e0b; + border-radius: 0.25rem; + background-color: #fffbeb; + white-space: pre-line; +} + +body[data-theme='dark'] .storefront-incoming-order__note { + background-color: rgb(120 53 15 / 25%); +} + +.storefront-incoming-order__codes { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.storefront-incoming-order__qr, +.storefront-incoming-order__barcode { + height: 3.5rem; + padding: 0.25rem; + border-radius: 0.375rem; + background-color: #fff; +} + +.storefront-incoming-order__qr { + width: 3.5rem; +} + +.storefront-incoming-order__barcode { + width: 10rem; +} + +@media (width <= 639px) { + .storefront-incoming-order__people { + grid-template-columns: minmax(0, 1fr); + } + + .storefront-incoming-order__ref { + flex-direction: column; + align-items: flex-start; + } + + .storefront-incoming-order__chips { + justify-content: flex-start; + } +} diff --git a/app/components/storefront/order/line-items.js b/app/components/storefront/order/line-items.js new file mode 100644 index 00000000..09c4da22 --- /dev/null +++ b/app/components/storefront/order/line-items.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/line-items'; diff --git a/tests/integration/components/modals/incoming-order-test.js b/tests/integration/components/modals/incoming-order-test.js index bc41da61..ea58897f 100644 --- a/tests/integration/components/modals/incoming-order-test.js +++ b/tests/integration/components/modals/incoming-order-test.js @@ -1,22 +1,69 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +function order(overrides = {}) { + return { + public_id: 'order_abc', + tracking: 'FLE123MN', + status: 'created', + notes: '', + createdAgo: '2 minutes ago', + customer: { id: 'c1', name: 'Bold Batbayar', phone: '+97699110001', email: 'bold@example.mn' }, + driver_assigned: null, + tracking_number: { qr_code: 'QR', barcode: 'BAR' }, + payload: { + pickup: { name: 'Zaisan', address: 'Zaisan, Ulaanbaatar' }, + dropoff: { name: 'T2', address: 'T2 - 26 Peace Avenue' }, + entities: [{ name: 'Milk tea', description: 'Hot', meta: { quantity: 2, subtotal: 4000 } }], + }, + meta: { currency: 'MNT', subtotal: 4000, delivery_fee: 1000, total: 5000, tip: '0', gateway: 'qpay' }, + ...overrides, + }; +} + module('Integration | Component | modals/incoming-order', function (hooks) { setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('a delivery reads as a ticket: store and total, tracking number, route, people, items and label codes', async function (assert) { + this.set('options', { title: 'New order', order: order(), store: { name: 'Khaan Buuz', currency: 'MNT' } }); - // Modals read their @options (network, store, catalog) in tasks on render; a bare render has nothing to assert. - test.skip('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + await render(hbs``); + + assert.dom('[data-test-incoming-order-store]').hasText('Khaan Buuz'); + assert.dom('[data-test-incoming-order-tracking]').hasText('FLE123MN'); + assert.dom('[data-test-incoming-order]').includesText('order_abc'); + assert.dom('[data-test-incoming-order]').includesText('2 items', 'the item count follows the quantities'); + assert.dom('[data-test-incoming-order-timing]').includesText('ASAP'); + assert.dom('[data-test-incoming-order-route] li').exists({ count: 2 }, 'store location and drop-off'); + assert.dom('[data-test-incoming-order-route]').includesText('T2 - 26 Peace Avenue'); + assert.dom('[data-test-incoming-order-route]').doesNotIncludeText('T2 ·', 'a name the address starts with is not repeated'); + assert.dom('[data-test-incoming-order]').includesText('+97699110001'); + assert.dom('[data-test-incoming-order]').includesText('No driver assigned'); + assert.dom('[data-test-incoming-order-codes] img').exists({ count: 2 }); + assert.dom('[data-test-incoming-order-note]').doesNotExist('no note, no section'); + assert.dom('[data-test-incoming-order]').doesNotIncludeText('Tip', 'a zero tip is left out'); + }); - await render(hbs``); + test('a pickup has one stop, needs no driver and shows the customer note', async function (assert) { + this.set('options', { + order: order({ + notes: 'Ring the bell', + meta: { currency: 'MNT', subtotal: 4000, total: 4000, is_pickup: true }, + payload: { pickup: { name: 'Counter', address: 'Bagatoiruu 12' }, entities: [] }, + }), + store: { name: 'Khaan Buuz' }, + }); - assert.dom(this.element).exists(); + await render(hbs``); - // Template block usage: - await render(hbs``); - assert.dom(this.element).exists(); + assert.dom('[data-test-incoming-order-route] li').exists({ count: 1 }); + assert.dom('[data-test-incoming-order-route]').includesText('Customer collects from'); + assert.dom('[data-test-incoming-order]').includesText('No driver needed'); + assert.dom('[data-test-incoming-order-assign-driver]').doesNotExist(); + assert.dom('[data-test-incoming-order-note]').hasText('Ring the bell'); }); }); diff --git a/tests/integration/components/storefront/order/line-items-test.js b/tests/integration/components/storefront/order/line-items-test.js new file mode 100644 index 00000000..16b86586 --- /dev/null +++ b/tests/integration/components/storefront/order/line-items-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | storefront/order/line-items', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('it lists the items and totals, with tips only when there is one', async function (assert) { + this.set('order', { + payload: { entities: [{ name: 'Buuz', meta: { quantity: 3, subtotal: 9000, addons: [{ name: 'Extra sauce' }] } }] }, + meta: { currency: 'MNT', subtotal: 9000, delivery_fee: 1000, total: 11000, tip: '10%', delivery_tip: 0, promotions: [{ name: 'Lunch', code: 'LUNCH', amount: 500 }] }, + }); + + await render(hbs``); + + assert.dom('.storefront-order-item').exists({ count: 1 }); + assert.dom('.storefront-order-item__qty').hasText('3x'); + assert.dom('.storefront-order-item').includesText('+ Extra sauce'); + assert.dom('.storefront-order-totals').includesText('Lunch'); + assert.dom('.storefront-order-totals').includesText('Tip', 'a percentage tip shows'); + assert.dom('.storefront-order-totals').doesNotIncludeText('Delivery tip', 'a zero delivery tip is left out'); + }); +}); diff --git a/tests/integration/components/storefront/truck/card-test.js b/tests/integration/components/storefront/truck/card-test.js index b70ac89f..57c6a197 100644 --- a/tests/integration/components/storefront/truck/card-test.js +++ b/tests/integration/components/storefront/truck/card-test.js @@ -1,7 +1,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { setupIntl } from 'ember-intl/test-support'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; @@ -59,4 +59,16 @@ module('Integration | Component | storefront/truck/card', function (hooks) { assert.dom('[data-test-truck-area]').hasText('No service area'); assert.dom('[data-test-truck-vehicle] [data-test-resource-pill-title]').hasText('No vehicle assigned'); }); + + test('the title opens the truck, not the Fleet-Ops vehicle', async function (assert) { + const opened = []; + this.set('truck', { id: 't1', status: 'active', vehicle: { id: 'v1', display_name: 'Truck 10', online: true }, catalogs: [] }); + this.set('open', (truck) => opened.push(truck)); + + await render(hbs``); + await click('[data-test-truck-vehicle] [data-test-resource-pill-title]'); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0], this.truck); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 3e8f7b28..0a04f2a7 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -232,6 +232,15 @@ storefront: optionally-select-category: Optionally select a category to import products into select-category: Select Category incoming-order: + placed: Placed {ago} + items-count: "{count, plural, one {# item} other {# items}}" + paid-in-app: Paid in app + scheduled-for: For {date} + collects-from: Customer collects from + leaves-from: Leaves from + deliver-to: Deliver to + note: Note from the customer + scan-hint: Scan or print for handover assigned: Driver Assigned not-assigned: No driver assigned no-phone: No phone