diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index d58143299..157d770b0 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -5,9 +5,16 @@ name: API Contract (Postman) # fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN # (inherited); no-ops until POSTMAN_API_KEY is set. # -# Pin the workflow and host checkout together so branch installation resolves the -# required published Core release before migrating and exercising this module. -# The workflow records the API image digest and resolved dependencies for traceability. +# Deliberately unpinned, like storefront's. The reusable workflow defaults to booting +# fleetbase/fleetbase@main against fleetbase/fleetbase-api:latest, so every release and +# every fix to the run order (scripts/ci/order-collection-requests.py) is picked up +# automatically, with no ref here to remember to bump. Each run records the image digest +# it actually resolved in its job summary, so a result stays traceable. To reproduce an +# older run: +# +# with: +# fleetbase-ref: v0.7.53 +# api-image: fleetbase/fleetbase-api:v0.7.53 on: push: @@ -20,9 +27,8 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@880c7392c67c93e3a65f01916d7a04036c098935 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main with: - fleetbase-ref: 880c7392c67c93e3a65f01916d7a04036c098935 collections: "Fleetbase API" build-from-source: false # Without this the run tests the version of fleetbase/fleetops-api baked into the diff --git a/RELEASE.md b/RELEASE.md index 88f7e2c79..73554321a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,29 +1,26 @@ -> v0.6.71 ~ "Extensible tables and panels, configured order lifecycles and order presentation profiles" +> v0.6.72 ~ "Customer tracking page settings, safer public tracking and structured label codes" --- ## Highlights -- **Resource view registries.** Extensions can add the following through `fleet-ops::table:` and `fleet-ops::details:`: - - columns, row actions, bulk actions and toolbar buttons on every top-level Fleet-Ops table; - - header buttons and "…" menu items on its details panels and side panels. +- **Tracking Page settings.** A new Fleet-Ops → Settings → Tracking Page lets each organization configure the customer tracking page: + - its own page at `/t/{slug}`, with a live address check; + - whether its numbers can be tracked on the shared page; + - branding: display name, accent colour with a live contrast check, an optional dark-mode accent, support contacts, "Powered by Fleetbase", and languages; + - how customers verify (text message or email codes, session length, customer sign-in); + - what verified customers see. - Built-in items carry stable ids, so registered items can be placed before or after them. -- **Configured order lifecycles.** An order config can define its own lifecycle in `meta.lifecycle`: `initial`, `completed`, `canceled` and `terminal` activities, `dispatch: false` and `strict_transitions`. The internal API starts orders at the initial activity, refuses to dispatch when dispatch is off, accepts only the flow's transitions when strict, and cancels to the configured activity. Configs without a lifecycle behave as before. - - **Lifecycle tab** in Order Configuration, to view and edit a config's lifecycle. - - **The board** shows a configured lifecycle's activities as its columns. - - **The activity flow editor** roots at the configured initial activity, and handles flows with cycles. -- **Order presentation profiles.** An extension can give its order type its own order form sections, extra and hidden fields, details view sections and hidden actions, registered in `fleet-ops:order-presentation`. **Edit details** opens the profiled form. Other order types are unchanged. -- **The Order Configuration manager lists every config**, including configs created while the console is open. -- **An order's activity timeline follows status changes made on the server.** -- **Fix: the Activity Flow crashed on a flow with a cycle.** -- **Fix: order details now load the assigned vehicle with the order.** -- **Fix: the order form failed when its custom fields couldn't load.** -- **Fix: registered details tabs.** Opening a driver, vehicle or trailer side panel removed the route from tabs other extensions had registered, which broke them in the full details view. Side panels also left out the tabs they could render. -- **Fix: the sensor details view showed the tabs registered for places.** + Administrators set the shared page's defaults under Fleet-Ops Config → Tracking Page. The new tracking pages themselves arrive in a later release; these settings are what they will read. +- **Safer public order lookup.** `GET int/v1/fleet-ops/lookup` is rate limited per address and returns a minimal resource: tracking number, status, timeline, ETA, stop coordinates and items. It no longer returns notes, meta, internal ids, files, rates or customer details. The driver's location is included only while the order is under way. Unknown and malformed tracking numbers get the same answer. +- **Structured label codes.** Tracking-number QR codes now carry a tracking URL holding the tracking number and the owner's public id, which a phone camera opens as the tracking page. Barcodes use Code 128. Every scan endpoint still accepts the uuid codes already printed on parcels. +- **Fix: signatures on private S3 buckets.** Signatures were not stored on buckets that reject ACLs; they are now written without one and served through signed URLs. Legacy absolute avatar URLs on vehicles, drivers and places are re-signed when read. +- **Fix: the customer credentials email** linked to an unrouted page; it now links to `/customer-access/{slug}`. +- **Fix: parcel box images** on the service rate form and details showed broken images in production builds. --- ## Upgrading -Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5. +- Run `php artisan fleetbase:create-permissions` to register the new `tracking-page-settings` permission. +- Private-bucket signature storage pairs with fleetbase/core-api's `File::signStoredUrl()`; on older core-api releases stored avatar URLs pass through unchanged. --- ## Need help? diff --git a/addon/components/admin/tracking-page-settings.hbs b/addon/components/admin/tracking-page-settings.hbs new file mode 100644 index 000000000..c56711b8b --- /dev/null +++ b/addon/components/admin/tracking-page-settings.hbs @@ -0,0 +1,56 @@ + +
+
+
+
Enable the generic tracking page
+
Serves /~/track for every organization that allows it. Organizations can also run their own page from their Fleet-Ops settings.
+
+ +
+
+ + +

Shown before a lookup. The logo comes from the instance branding.

+
+
+ +
+ + {{this.accent}} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
Show "Powered by Fleetbase"
+
The default for organizations that haven't chosen.
+
+ +
+
+
+ + + + {{/if}} + + + + +
+ {{#if (or this.boot.isRunning (eq this.screen "loading"))}} +
+
+
+
+
+ {{else if (eq this.screen "unavailable")}} +
+

{{t "tracking-page.page.unavailable-title"}}

+

{{t "tracking-page.page.unavailable-help"}}

+
+ {{else if (eq this.screen "lookup")}} +
+
+

{{t "tracking-page.page.lookup-title"}}

+

{{t "tracking-page.page.lookup-sub"}}

+
+
+ + +

{{t "tracking-page.page.tracking-number-help"}}

+ + +
+ {{#if this.page.sign_in}} +
{{t "tracking-page.page.or"}}
+ {{#if this.signedIn}} + + {{else}} +
{{t "tracking-page.page.sign-in"}} + {{/if}} + {{/if}} +
+ {{else if (eq this.screen "public")}} + + {{else if (eq this.screen "verify")}} +
+ + +
+ {{else if (eq this.screen "list")}} + + {{else if (eq this.screen "track")}} + + {{/if}} +
+ +
+ {{#if (or this.company.support_phone this.company.support_email)}} +
+ {{t "tracking-page.page.help"}} + {{#if this.company.support_phone}}{{this.company.support_phone}}{{/if}} + {{#if (and this.company.support_phone this.company.support_email)}} · {{/if}} + {{#if this.company.support_email}}{{this.company.support_email}}{{/if}} +
+ {{/if}} + {{#if this.company.powered_by}} + + {{/if}} +
+ +
+ {{#if this.toast}} +
+ +
+
{{this.toast.title}}
+ {{#if this.toast.text}}
{{this.toast.text}}
{{/if}} +
+ +
+ {{/if}} +
+ diff --git a/addon/components/tracking-page.js b/addon/components/tracking-page.js new file mode 100644 index 000000000..6b8f70d75 --- /dev/null +++ b/addon/components/tracking-page.js @@ -0,0 +1,564 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import { task, timeout } from 'ember-concurrency'; +import trackingPageTheme from '../utils/tracking-page-theme'; +import TrackingLiveSource from '../utils/tracking-live-source'; + +/** + * The FleetOps API is mounted at the application root, so its public routes live under `public`. + * `rawError` makes a failed request reject with its JSON body, whose `code` or `error` drives the page. + */ +const REQUEST_OPTIONS = { namespace: 'public', rawError: true }; + +/** How many neutral misses in a row before the page suggests a short break. */ +const GENTLE_LIMIT_AFTER = 4; + +/** The code length the server issues. */ +export const CODE_LENGTH = 6; + +/** + * The customer tracking page. + * + * Registered in the `auth:login` registry as `t`, `track` and `track-order`, so the + * console's public `virtual` routes serve it outside the console, signed in or not: + * `/~/track/{number}` on the shared page and `/~/t/{slug}/{number}` on a company's own. + * The older `?order=` (or `?number=`) and `?org=` query links still open. + * + * A tracking number alone shows a coarse status. A one-time code to the customer's own + * contact details unlocks their stops on this device; a signed-in customer, or staff of + * the owning company, sees them straight away. Live updates poll the same endpoint. + */ +export default class TrackingPageComponent extends Component { + @service fetch; + @service intl; + @service urlSearchParams; + @service session; + + @tracked screen = 'loading'; + @tracked page = null; + @tracked data = null; + @tracked trackingInput = ''; + @tracked lookupError = null; + @tracked misses = 0; + @tracked liveState = 'live'; + @tracked toast = null; + @tracked orders = null; + @tracked prefersDark = false; + + /* verification */ + @tracked verifyStep = 'choose'; + @tracked channel = null; + @tracked masked = null; + @tracked code = ''; + @tracked attemptsLeft = 3; + @tracked cooldown = 0; + @tracked lockLeft = 0; + @tracked verifyMessage = null; + + live = null; + ticker = null; + + constructor() { + super(...arguments); + const fromPath = parseTrackingPath(window.location.pathname); + this.org = fromPath.org || this.urlSearchParams.get('org') || null; + this.trackingInput = fromPath.number || this.urlSearchParams.get('order') || this.urlSearchParams.get('number') || ''; + this.watchColorScheme(); + this.ticker = setInterval(() => this.tick(), 1000); + this.boot.perform(); + } + + willDestroy() { + super.willDestroy(...arguments); + clearInterval(this.ticker); + this.live?.stop(); + this.colorScheme?.removeEventListener?.('change', this.onColorScheme); + } + + /* ── Derived state ─────────────────────────────────────────────── */ + + get company() { + return this.data?.company ?? this.page?.company ?? {}; + } + + get monogram() { + let monogram = ''; + for (const word of String(this.company.name ?? '').split(/\s+/)) { + if (word && monogram.length < 2) { + monogram += word[0].toUpperCase(); + } + } + + return monogram; + } + + get dark() { + return this.company.theme !== 'light' && this.prefersDark; + } + + get themeStyle() { + return htmlSafe(trackingPageTheme(this.company, this.dark)); + } + + get locale() { + return this.intl.primaryLocale ?? 'en-us'; + } + + get number() { + return (this.data?.tracking_number ?? this.trackingInput ?? '').trim(); + } + + get isFull() { + return this.data?.level >= 1; + } + + get rateLimited() { + return this.misses >= GENTLE_LIMIT_AFTER; + } + + get channels() { + return this.data?.verify?.channels ?? []; + } + + get hasChannels() { + return this.channels.length > 0; + } + + get signInUrl() { + return `/customer-portal/auth/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + } + + get signedIn() { + return this.session.isAuthenticated === true; + } + + get cooldownText() { + return formatCountdown(this.cooldown); + } + + get lockText() { + return formatCountdown(this.lockLeft); + } + + get codeBoxes() { + const digits = this.code.split(''); + return Array.from({ length: CODE_LENGTH }, (_, index) => ({ + digit: digits[index] ?? '', + active: (this.verifyStep === 'sent' || this.verifyStep === 'wrong') && index === Math.min(digits.length, CODE_LENGTH - 1), + })); + } + + get selectedChannel() { + return this.channel ?? this.channels[0]?.type ?? null; + } + + get otherChannel() { + return this.channels.find((channel) => channel.type !== this.selectedChannel) ?? null; + } + + /* ── Loading ───────────────────────────────────────────────────── */ + + @task *boot() { + try { + this.page = yield this.fetch.get('track/config', this.query(), REQUEST_OPTIONS); + this.applyLocale(this.page?.company); + } catch { + this.screen = 'unavailable'; + return; + } + + if (this.trackingInput) { + yield this.lookup.perform(this.trackingInput, { quiet: true }); + } else { + this.screen = 'lookup'; + } + + if (this.signedIn && !this.data) { + this.loadOrders.perform(); + } + } + + @task({ restartable: true }) *lookup(number, { quiet = false } = {}) { + this.lookupError = null; + const value = String(number ?? '').trim(); + if (!value) { + this.lookupError = 'neutral'; + return; + } + + try { + const data = yield this.fetch.get(`track/${encodeURIComponent(value)}`, this.query(), REQUEST_OPTIONS); + this.misses = 0; + this.show(data); + this.replaceUrl(data.tracking_number); + } catch (error) { + this.misses += 1; + this.lookupError = this.rateLimited ? 'rate' : 'neutral'; + this.screen = 'lookup'; + if (quiet) { + this.misses = Math.max(0, this.misses - 1); + } + return error; + } + } + + @task *refresh() { + return yield this.fetch.get(`track/${encodeURIComponent(this.number)}`, this.query(), REQUEST_OPTIONS); + } + + @task *loadOrders() { + try { + const response = yield this.fetch.get('track/account/orders', {}, REQUEST_OPTIONS); + this.orders = response?.orders ?? []; + if (!this.data) { + this.screen = 'list'; + } + } catch { + this.orders = null; + } + } + + show(data) { + const before = this.data; + this.data = data; + this.applyLocale(data?.company); + this.screen = data?.level >= 1 ? 'track' : 'public'; + + if (before && before.stage !== data.stage && data.level >= 1) { + this.notify(this.intl.t(`tracking-page.page.stage-toast-${data.stage}`), ''); + } + + if (!this.isFull) { + this.live?.stop(); + } else if (!this.live?.running) { + this.startLive(); + } + } + + startLive() { + this.live?.stop(); + + this.live = new TrackingLiveSource(() => this.refresh.perform(), { isActive: () => this.data?.stage === 'in_transit' }); + this.live.start(this.onLiveUpdate, this.onLiveState); + } + + onLiveUpdate = (data) => { + this.show(data); + }; + + onLiveState = (state) => { + this.liveState = state; + }; + + /* ── Lookup ────────────────────────────────────────────────────── */ + + @action updateTracking(event) { + this.trackingInput = event.target.value; + } + + @action submitLookup(event) { + event?.preventDefault?.(); + if (this.rateLimited) { + return; + } + + this.lookup.perform(this.trackingInput); + } + + @action lookupAnother() { + this.live?.stop(); + this.data = null; + this.trackingInput = ''; + this.lookupError = null; + this.replaceUrl(null); + this.screen = 'lookup'; + } + + @action openOrder(order) { + this.trackingInput = order.tracking_number; + this.lookup.perform(order.tracking_number); + } + + @action backToOrders() { + this.live?.stop(); + this.data = null; + this.replaceUrl(null); + this.screen = 'list'; + } + + /* ── Verification ──────────────────────────────────────────────── */ + + @action startVerify() { + this.verifyStep = this.hasChannels ? 'choose' : 'none'; + this.channel = this.channels[0]?.type ?? null; + this.code = ''; + this.verifyMessage = null; + this.screen = 'verify'; + } + + @action cancelVerify() { + this.screen = 'public'; + } + + @action pickChannel(type) { + this.channel = type; + } + + @action switchChannel() { + if (this.otherChannel) { + this.channel = this.otherChannel.type; + this.sendCode.perform(); + } + } + + @task({ drop: true }) *sendCode() { + this.verifyStep = 'sending'; + this.code = ''; + this.verifyMessage = null; + + try { + const response = yield this.fetch.post(`track/${encodeURIComponent(this.number)}/codes${this.queryString()}`, { channel: this.selectedChannel }, REQUEST_OPTIONS); + this.masked = response.masked; + this.cooldown = response.resend_after ?? 30; + this.attemptsLeft = 3; + this.verifyStep = 'sent'; + } catch (error) { + this.handleVerifyError(error); + } + } + + @action resend() { + if (this.cooldown === 0) { + this.sendCode.perform(); + } + } + + @action updateCode(event) { + if (['locked', 'sending', 'success'].includes(this.verifyStep)) { + return; + } + + this.code = String(event.target.value ?? '') + .replace(/\D/g, '') + .slice(0, CODE_LENGTH); + if (this.verifyStep === 'wrong') { + this.verifyStep = 'sent'; + } + + if (this.code.length === CODE_LENGTH) { + this.verifyCode.perform(this.code); + } + } + + @task({ drop: true }) *verifyCode(code) { + try { + const data = yield this.fetch.post(`track/${encodeURIComponent(this.number)}/codes/verify${this.queryString()}`, { code }, REQUEST_OPTIONS); + this.verifyStep = 'success'; + yield timeout(700); + this.show(data); + this.notify(this.intl.t('tracking-page.page.verified-toast'), this.intl.t('tracking-page.page.verified-toast-help')); + } catch (error) { + this.code = ''; + this.handleVerifyError(error); + } + } + + handleVerifyError(error) { + const code = error?.error ?? error?.code; + if (code === 'invalid') { + this.attemptsLeft = error.attempts_left ?? 0; + this.verifyStep = 'wrong'; + } else if (code === 'paused') { + this.lockLeft = error.retry_after ?? 300; + this.verifyStep = 'locked'; + } else if (code === 'cooldown') { + this.cooldown = error.retry_after ?? 30; + this.verifyStep = 'sent'; + } else if (code === 'expired') { + this.verifyStep = 'renew'; + this.verifyMessage = 'expired'; + } else if (code === 'limited') { + this.verifyStep = 'renew'; + this.verifyMessage = 'limited'; + } else { + this.verifyStep = this.hasChannels ? 'choose' : 'none'; + this.verifyMessage = 'failed'; + } + } + + @task *signOut() { + try { + yield this.fetch.delete(`track/session${this.queryString()}`, {}, REQUEST_OPTIONS); + } catch { + // Signed out locally either way: the next lookup shows what the server allows. + } + + this.live?.stop(); + this.notify(this.intl.t('tracking-page.page.signed-out-toast'), ''); + yield this.lookup.perform(this.number); + } + + /* ── Full view actions ─────────────────────────────────────────── */ + + @task *saveInstructions(text) { + try { + const data = yield this.fetch.put(`track/${encodeURIComponent(this.number)}/instructions${this.queryString()}`, { text }, REQUEST_OPTIONS); + this.show(data); + this.notify(this.intl.t('tracking-page.page.instructions-saved'), ''); + } catch { + this.notify(this.intl.t('tracking-page.page.action-failed'), ''); + } + } + + @task *reportProblem(message) { + try { + yield this.fetch.post(`track/${encodeURIComponent(this.number)}/report${this.queryString()}`, { message }, REQUEST_OPTIONS); + this.notify(this.intl.t('tracking-page.page.report-sent'), this.intl.t('tracking-page.page.report-sent-help', { name: this.company.name })); + return true; + } catch { + this.notify(this.intl.t('tracking-page.page.action-failed'), ''); + return false; + } + } + + @task *loadProof(id) { + return yield this.fetch.get(`track/${encodeURIComponent(this.number)}/proofs/${encodeURIComponent(id)}`, this.query(), REQUEST_OPTIONS); + } + + @action share() { + const url = `${window.location.origin}${trackingPath(this.org, this.number)}`; + try { + if (navigator.share) { + navigator.share({ url }).catch(() => {}); + } else { + navigator.clipboard?.writeText(url); + } + } catch { + // Copying is a convenience; the address bar still has the link. + } + + this.notify(this.intl.t('tracking-page.page.share-copied'), this.intl.t('tracking-page.page.share-copied-help')); + } + + /* ── Page chrome ───────────────────────────────────────────────── */ + + @action dismissToast() { + this.toast = null; + } + + notify(title, text) { + this.toast = { title, text, id: Date.now() }; + const id = this.toast.id; + setTimeout(() => { + if (this.toast?.id === id && !this.isDestroyed) { + this.toast = null; + } + }, 4500); + } + + tick() { + if (this.cooldown > 0) { + this.cooldown -= 1; + } + + if (this.verifyStep === 'locked') { + if (this.lockLeft > 1) { + this.lockLeft -= 1; + } else { + this.lockLeft = 0; + this.verifyStep = 'renew'; + this.verifyMessage = 'unlocked'; + } + } + } + + applyLocale(company) { + const preferred = this.urlSearchParams.get('lang') || company?.default_locale; + if (!this.localeApplied && preferred && company?.locales?.includes(preferred)) { + this.localeApplied = true; + this.intl.setLocale([preferred]); + } + } + + watchColorScheme() { + if (typeof window === 'undefined' || !window.matchMedia) { + return; + } + + this.colorScheme = window.matchMedia('(prefers-color-scheme: dark)'); + this.prefersDark = this.colorScheme.matches; + this.onColorScheme = (event) => { + this.prefersDark = event.matches; + }; + this.colorScheme.addEventListener?.('change', this.onColorScheme); + } + + /** + * Keep the address bar on the page's own link, so a reload or a copied link opens the + * same order. The console router never sees it: the page stays on the same route. + */ + replaceUrl(number) { + try { + window.history.replaceState(window.history.state, '', trackingPath(this.org, number)); + } catch { + // The address bar is a convenience; the page already shows the order. + } + } + + query() { + return this.org ? { org: this.org } : {}; + } + + queryString() { + return this.org ? `?org=${encodeURIComponent(this.org)}` : ''; + } +} + +/** + * The page's link: `/~/t/{slug}/{number}` on a company's own page, `/~/track/{number}` + * on the shared one; without a number, the page's lookup screen. + */ +export function trackingPath(org, number) { + const base = org ? `/~/t/${encodeURIComponent(org)}` : '/~/track'; + + return number ? `${base}/${encodeURIComponent(number)}` : base; +} + +/** + * The company slug and tracking number in a page link's path, when it carries them. + */ +export function parseTrackingPath(pathname) { + const segments = String(pathname ?? '') + .split('/') + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); + const start = segments.indexOf('~'); + const [page, ...rest] = start === -1 ? [] : segments.slice(start + 1); + + if (page === 't') { + return { org: rest[0] || null, number: rest[1] || null }; + } + + if (page === 'track' || page === 'track-order') { + return { org: null, number: rest[0] || null }; + } + + return { org: null, number: null }; +} + +export function formatCountdown(seconds) { + const value = Math.max(0, Math.floor(seconds ?? 0)); + const minutes = Math.floor(value / 60); + const rest = value % 60; + + return `${minutes}:${rest < 10 ? '0' : ''}${rest}`; +} diff --git a/addon/components/tracking-page/full-view.hbs b/addon/components/tracking-page/full-view.hbs new file mode 100644 index 000000000..f8f254640 --- /dev/null +++ b/addon/components/tracking-page/full-view.hbs @@ -0,0 +1,276 @@ +
+
+ {{#if @fromList}} + + {{/if}} + {{this.viewerChip.label}} + {{#if this.canSignOut}} + + {{/if}} +
+ +
+ {{#if this.hasSeveralStops}} +
+ {{#each this.stopOptions as |option|}} + + {{/each}} +
+ {{/if}} + +
+
+

{{this.headline}}

+ {{#if this.subline}} +

{{this.subline}}

+ {{/if}} +
+
+ {{#if this.live}} + {{t "tracking-page.page.live"}} + {{/if}} + {{#if (eq @liveState "reconnecting")}} + {{t "tracking-page.page.reconnecting"}} + {{/if}} + {{#if this.timezoneNote}}{{this.timezoneNote}}{{/if}} +
+
+ +
    + {{#each this.steps as |step|}} +
  1. +
    + + {{#if step.isDone}}{{/if}} + {{#if step.isFailed}}{{/if}} + {{#if step.isStop}}{{/if}} + + {{#unless step.last}}{{/unless}} +
    + {{step.label}} +
  2. + {{/each}} +
+ + {{#if this.delayed}} +
+ + {{t "tracking-page.page.delayed-title"}} {{t "tracking-page.page.delayed-help"}} +
+ {{/if}} + + {{#if this.isMultiStop}} +
+

{{t "tracking-page.page.stop-of" position=this.stopPosition total=this.route.total_stops}}

+

{{t "tracking-page.page.stops-private" count=this.route.stops_before}}

+
+ {{/if}} + +
+
+ {{t "tracking-page.page.tracking-number"}} + {{@data.tracking_number}} +
+ {{t "tracking-page.page.copy"}} +
+ + {{#if this.currentStop}} +
+
+

{{this.stopCardTitle}}

+ {{#if this.currentStop.place.name}}{{this.currentStop.place.name}}{{/if}} + {{#if this.currentStop.place.address}}{{this.currentStop.place.address}}{{/if}} + {{#if this.windowText}}{{t "tracking-page.page.window"}} {{this.windowText}}{{/if}} +
+ + {{#if this.currentStop.complete}} {{t "tracking-page.page.stop-complete"}}{{else}}{{t "tracking-page.page.stop-upcoming"}}{{/if}} + +
+ {{/if}} +
+ + {{#if @data.map}} + {{#if (or @data.location (gt this.stops.length 0))}} +
+ +
+ {{/if}} + {{else if (eq this.stage "delivered")}} +

{{t "tracking-page.page.map-hidden"}}

+ {{/if}} + +
+ {{#if (or this.etaCard this.driver)}} +
+ {{#if this.etaCard}} +
+

{{t "tracking-page.page.eta"}}

+
+ {{this.etaCard.time}} + {{#if this.etaCard.relative}}{{this.etaCard.relative}}{{/if}} +
+
+ {{#if this.etaCard.window}} +
{{t "tracking-page.page.window"}}
{{this.etaCard.window}}
+ {{/if}} +
{{t "tracking-page.page.before-yours"}}
{{t "tracking-page.page.stops-count" count=this.etaCard.stopsBefore}}
+
+ {{#if this.etaCard.low}} + {{t "tracking-page.page.estimate-may-change"}} + {{/if}} +
+ {{/if}} + {{#if this.driver}} +
+

{{t "tracking-page.page.driver"}}

+
+ +
+ {{#if this.driver.name}}{{this.driver.name}}{{/if}} + {{#if this.driver.vehicle}}{{this.driver.vehicle}}{{/if}} + + {{#if this.driver.online}}{{t "tracking-page.page.online"}}{{else}}{{t "tracking-page.page.location-paused"}}{{/if}} + +
+
+ {{#if this.contactPhone}} + +

{{t "tracking-page.page.contact-note" name=@company.name}}

+ {{/if}} +
+ {{/if}} +
+ {{/if}} + + {{#if (gt this.proofs.length 0)}} +
+

{{t "tracking-page.page.proof"}}

+
+ {{#each this.proofs as |proof|}} + {{#if proof.url}} + + {{else}} +
+ + {{t (concat "tracking-page.page.proof-" proof.type)}} +
+ {{/if}} + {{/each}} +
+
+ {{/if}} + + {{#if (or this.instructions.text this.instructions.editable)}} +
+
+

{{t "tracking-page.page.instructions"}}

+ {{#if (and this.instructions.editable (not this.editing))}} + + {{/if}} +
+ {{#if this.editing}} + + +
+ + +
+ {{else}} +

{{or this.instructions.text (t "tracking-page.page.no-instructions")}}

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

{{t "tracking-page.page.updates"}}

+ {{#if this.hasOlder}} + + {{/if}} + {{#each this.timelineGroups as |group|}} +
{{group.day}}
+
    + {{#each group.entries as |entry|}} +
  1. + +
    +
    + {{entry.status}} + {{#if entry.place}}{{entry.place}}{{/if}} +
    + {{entry.time}} +
    +
  2. + {{/each}} +
+ {{else}} +

{{t "tracking-page.page.no-updates"}}

+ {{/each}} +
+ + {{#if (gt this.items.length 0)}} +
+

{{t "tracking-page.page.items"}}

+ {{#each this.items as |item|}} +
+ +
+ {{or item.name (t "tracking-page.page.item")}} + {{#if item.description}}{{item.description}}{{/if}} + {{#if item.quantity}}{{t "tracking-page.page.quantity" count=item.quantity}}{{/if}} + {{#if item.tracking}}{{item.tracking}}{{/if}} +
+ {{#if item.price}}{{format-currency item.price item.currency}}{{/if}} +
+ {{/each}} +
+ {{/if}} + +
+ + {{#if this.canReport}} + + {{/if}} + {{#if this.contactEmail}} + {{t "tracking-page.page.contact" name=@company.name}} + {{/if}} +
+ {{#if this.reporting}} +
+ + +
+ + +
+
+ {{/if}} +
+ + {{#if this.lightbox}} + + {{/if}} +
diff --git a/addon/components/tracking-page/full-view.js b/addon/components/tracking-page/full-view.js new file mode 100644 index 000000000..26270c5dc --- /dev/null +++ b/addon/components/tracking-page/full-view.js @@ -0,0 +1,474 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * Coarse stages, in order, and how many steps each one has completed: a dispatched order + * has been created and dispatched, and is waiting on the pickup, as its updates say. + */ +const STEPS = ['created', 'dispatched', 'started', 'enroute', 'delivered']; +const REACHED = { preparing: 1, scheduled: 1, dispatched: 2, in_transit: 3, delivered: 5 }; + +function step(key, state, label, last) { + return { + key, + state, + label, + last, + isDone: state === 'done', + isCurrent: state === 'current', + isFailed: state === 'failed', + isStop: state === 'stop', + }; +} + +/** + * One customer's view of their delivery: status, progress, live map, arrival, driver, + * updates, items and proof of delivery. Everything it shows has already been filtered to + * that customer's stops by the server. + */ +export default class TrackingPageFullViewComponent extends Component { + @service intl; + + @tracked showOlder = false; + @tracked editing = false; + @tracked draft = ''; + @tracked reporting = false; + @tracked reportText = ''; + @tracked proofUrls = {}; + @tracked lightbox = null; + @tracked selectedStopId = null; + + get data() { + return this.args.data ?? {}; + } + + get stage() { + return this.data.stage ?? 'preparing'; + } + + get delayed() { + return this.data.status?.delayed === true && this.stage === 'in_transit'; + } + + get tone() { + if (this.stage === 'issue' || this.delayed) { + return 'warn'; + } + + return this.stage === 'canceled' ? 'muted' : 'normal'; + } + + get viewerChip() { + const viewer = this.data.viewer; + if (viewer === 'staff') { + return { icon: 'user-shield', label: this.intl.t('tracking-page.page.chip-staff') }; + } + + if (viewer === 'account') { + return { icon: 'user', label: this.intl.t('tracking-page.page.chip-account') }; + } + + return { icon: 'shield-halved', label: this.intl.t('tracking-page.page.chip-verified') }; + } + + get canSignOut() { + return this.data.viewer === 'verified'; + } + + /* ── Stops ─────────────────────────────────────────────────────── */ + + get stops() { + return this.data.stops ?? []; + } + + get hasSeveralStops() { + return this.stops.length > 1; + } + + get stopOptions() { + return this.stops.map((stop, index) => ({ + id: stop.id, + label: this.intl.t('tracking-page.page.your-stop', { number: index + 1 }), + selected: stop.id === this.currentStop?.id, + complete: stop.complete, + })); + } + + get currentStop() { + const chosen = this.stops.find((stop) => stop.id === (this.selectedStopId ?? this.data.focus_stop)); + + return chosen ?? this.stops.find((stop) => !stop.complete) ?? this.stops[this.stops.length - 1] ?? null; + } + + get route() { + return this.data.route ?? { total_stops: 0, completed_stops: 0, stops_before: 0 }; + } + + get isMultiStop() { + return this.route.total_stops > 2 && this.stage === 'in_transit'; + } + + get stopPosition() { + return this.route.completed_stops + this.route.stops_before + 1; + } + + get currentStopNumber() { + return this.stops.indexOf(this.currentStop) + 1; + } + + get stopCardTitle() { + return this.hasSeveralStops ? this.intl.t('tracking-page.page.your-stop', { number: this.currentStopNumber }) : this.intl.t('tracking-page.page.stop-card-title'); + } + + @action selectStop(id) { + this.selectedStopId = id; + } + + /** + * Entries that belong to one stop, filtered to the selected one; the rest are the + * whole order's and always show. + */ + forCurrentStop(entries) { + if (!this.hasSeveralStops || !this.currentStop) { + return entries; + } + + return entries.filter((entry) => !entry.stop || entry.stop === this.currentStop.id); + } + + /* ── Headline ──────────────────────────────────────────────────── */ + + get eta() { + return this.currentStop?.eta ?? this.data.eta ?? null; + } + + get window() { + const own = this.currentStop?.window; + if (own?.start || own?.end) { + return own; + } + + return this.data.time_window ?? {}; + } + + get windowText() { + const { start, end } = this.window; + if (start && end) { + return `${this.time(start)} – ${this.time(end)}`; + } + + return start ? this.time(start) : end ? this.time(end) : null; + } + + get headline() { + const t = (key, values) => this.intl.t(`tracking-page.page.${key}`, values); + switch (this.stage) { + case 'scheduled': + return t('headline-scheduled', { date: this.date(this.data.scheduled_at) }); + case 'dispatched': + return t('headline-dispatched'); + case 'in_transit': + if (this.delayed) { + return t('headline-delayed'); + } + + return this.eta?.at ? t('headline-arriving', { day: this.relativeDay(this.eta.at) }) : t('headline-in-transit'); + case 'delivered': + return t('headline-delivered'); + case 'issue': + return t('headline-issue'); + case 'canceled': + return t('headline-canceled'); + default: + return t('headline-preparing'); + } + } + + get subline() { + const t = (key, values) => this.intl.t(`tracking-page.page.${key}`, values); + if (this.stage === 'in_transit' && this.eta?.at) { + const relative = this.minutesUntil(this.eta.at); + const window = this.windowText ?? this.time(this.eta.at); + + return relative > 0 ? t('sub-arriving', { window, minutes: relative }) : window; + } + + if (this.stage === 'scheduled') { + return this.windowText ? t('sub-window', { window: this.windowText }) : null; + } + + if (this.stage === 'delivered') { + const delivered = this.timeline.find((entry) => entry.complete) ?? this.timeline[0]; + + return delivered?.created_at ? t('sub-delivered', { when: this.dateTime(delivered.created_at) }) : null; + } + + if (this.stage === 'issue' || this.stage === 'canceled') { + return this.timeline[0]?.status ?? null; + } + + return this.stage === 'dispatched' ? t('sub-dispatched') : t('sub-preparing'); + } + + get live() { + return this.stage === 'in_transit' && !!this.data.location && this.args.liveState === 'live' && !this.stale; + } + + get stale() { + const seenAt = this.data.location?.seen_at; + if (!seenAt) { + return false; + } + + return (Date.now() - new Date(seenAt).getTime()) / 1000 > (this.data.stale_after ?? 300); + } + + get staleText() { + const minutes = Math.max(1, Math.round((Date.now() - new Date(this.data.location.seen_at).getTime()) / 60000)); + + return this.intl.t('tracking-page.page.last-seen', { minutes }); + } + + get timezoneNote() { + try { + const format = new Intl.DateTimeFormat(this.locale, { timeZoneName: 'short' }); + const parts = format.formatToParts(new Date()); + const zone = parts.find((part) => part.type === 'timeZoneName')?.value; + + return zone ? this.intl.t('tracking-page.page.timezone', { zone }) : null; + } catch { + return null; + } + } + + /* ── Stepper ───────────────────────────────────────────────────── */ + + get steps() { + if (this.stage === 'canceled') { + return [step('created', 'done', this.intl.t('tracking-page.page.step-created'), false), step('canceled', 'stop', this.intl.t('tracking-page.page.step-canceled'), true)]; + } + + const reached = REACHED[this.stage] ?? 3; + + return STEPS.map((key, index) => { + let state = index < reached ? 'done' : index === reached ? 'current' : 'todo'; + let label = this.intl.t(`tracking-page.page.step-${key}`); + if (this.stage === 'issue' && key === 'enroute') { + state = 'failed'; + label = this.intl.t('tracking-page.page.step-issue'); + } + + return step(key, state, label, index === STEPS.length - 1); + }); + } + + /* ── Cards ─────────────────────────────────────────────────────── */ + + get etaCard() { + if (this.stage !== 'in_transit' || !this.eta?.at) { + return null; + } + + return { + time: this.time(this.eta.at), + relative: this.minutesUntil(this.eta.at) > 0 ? this.intl.t('tracking-page.page.in-minutes', { minutes: this.minutesUntil(this.eta.at) }) : null, + window: this.windowText, + stopsBefore: this.route.stops_before, + low: this.delayed || ['low', 'medium'].includes(this.eta.confidence ?? this.data.status?.confidence), + }; + } + + get driver() { + return this.stage === 'in_transit' ? this.data.driver : null; + } + + get driverInitial() { + return (this.driver?.name ?? '?').charAt(0).toUpperCase(); + } + + get contactPhone() { + return this.args.company?.support_phone; + } + + get contactEmail() { + return this.args.company?.support_email; + } + + /** + * Newest first, as the server sends it. + */ + get timeline() { + return this.forCurrentStop(this.data.timeline ?? []); + } + + /** + * The updates as they read, oldest first, so the order's creation leads and the + * latest update closes the list, as the progress line does. Earlier days fold away. + */ + get timelineGroups() { + const groups = []; + const entries = [...this.timeline].reverse(); + entries.forEach((entry, index) => { + const day = this.dayLabel(entry.created_at); + let group = groups[groups.length - 1]; + if (!group || group.day !== day) { + group = { day, entries: [] }; + groups.push(group); + } + + group.entries.push({ ...entry, time: this.time(entry.created_at), latest: index === entries.length - 1 }); + }); + + return this.showOlder ? groups : groups.slice(-1); + } + + get olderCount() { + const shown = this.timelineGroups[this.timelineGroups.length - 1]?.entries.length ?? 0; + + return Math.max(0, this.timeline.length - (this.showOlder ? this.timeline.length : shown)); + } + + get hasOlder() { + return this.showOlder || this.olderCount > 0; + } + + @action toggleOlder() { + this.showOlder = !this.showOlder; + } + + get items() { + return this.forCurrentStop(this.data.items ?? []); + } + + get proofs() { + return this.forCurrentStop(this.data.proofs ?? []).map((proof) => ({ ...proof, url: this.proofUrls[proof.id] ?? null })); + } + + @action async loadProofs() { + for (const proof of this.data.proofs ?? []) { + if (this.proofUrls[proof.id] || proof.type === 'scan') { + continue; + } + + try { + const response = await this.args.loadProof(proof.id); + this.proofUrls = { ...this.proofUrls, [proof.id]: response?.url ?? null }; + } catch { + // The proof is listed even when its image can't be fetched. + } + } + } + + @action openProof(proof) { + if (proof.url) { + this.lightbox = proof; + } + } + + @action closeProof() { + this.lightbox = null; + } + + @action focusElement(element) { + element.focus(); + } + + @action closeOnEscape(event) { + if (event.key === 'Escape') { + this.closeProof(); + } + } + + /* ── Instructions and reports ──────────────────────────────────── */ + + get instructions() { + return this.data.instructions ?? {}; + } + + @action editInstructions() { + this.draft = this.instructions.text ?? ''; + this.editing = true; + } + + @action updateDraft(event) { + this.draft = event.target.value; + } + + @action cancelInstructions() { + this.editing = false; + } + + @action async saveInstructions() { + await this.args.saveInstructions(this.draft); + this.editing = false; + } + + @action toggleReport() { + this.reporting = !this.reporting; + } + + @action updateReport(event) { + this.reportText = event.target.value; + } + + @action async sendReport() { + const sent = await this.args.reportProblem(this.reportText); + if (sent) { + this.reportText = ''; + this.reporting = false; + } + } + + get canReport() { + return this.data.actions?.report === true && !!this.contactEmail; + } + + /* ── Formatting ────────────────────────────────────────────────── */ + + get locale() { + return this.intl.primaryLocale ?? 'en-us'; + } + + time(value) { + return value ? new Intl.DateTimeFormat(this.locale, { hour: 'numeric', minute: '2-digit' }).format(new Date(value)) : ''; + } + + date(value) { + return value ? new Intl.DateTimeFormat(this.locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(new Date(value)) : ''; + } + + dateTime(value) { + return `${this.dayLabel(value)}, ${this.time(value)}`; + } + + dayLabel(value) { + if (!value) { + return ''; + } + + const day = new Date(value); + const today = new Date(); + const yesterday = new Date(today.getTime() - 86400000); + if (day.toDateString() === today.toDateString()) { + return this.intl.t('tracking-page.page.today'); + } + + if (day.toDateString() === yesterday.toDateString()) { + return this.intl.t('tracking-page.page.yesterday'); + } + + return this.date(value); + } + + relativeDay(value) { + const day = this.dayLabel(value); + + return day === this.intl.t('tracking-page.page.today') ? day.toLowerCase() : day; + } + + minutesUntil(value) { + return Math.round((new Date(value).getTime() - Date.now()) / 60000); + } +} diff --git a/addon/components/tracking-page/locale-selector.js b/addon/components/tracking-page/locale-selector.js new file mode 100644 index 000000000..971ff7779 --- /dev/null +++ b/addon/components/tracking-page/locale-selector.js @@ -0,0 +1,30 @@ +import LocaleSelectorTrayComponent from '@fleetbase/ember-ui/components/locale-selector-tray'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * The console's locale switcher, for the tracking page: it offers only the languages the + * company's page is in (`@locales`), and switches the page without saving the choice to + * an account, since the visitor is most often a customer who isn't signed in. + */ +export default class TrackingPageLocaleSelectorComponent extends LocaleSelectorTrayComponent { + @service('language') languageService; + + get language() { + const all = this.languageService.availableLocales ?? {}; + const enabled = this.args.locales ?? []; + const availableLocales = {}; + for (const code of Object.keys(all)) { + if (all[code] && (enabled.length === 0 || enabled.includes(code))) { + availableLocales[code] = all[code]; + } + } + + return { availableLocales, loadAvailableCountries: this.languageService.loadAvailableCountries }; + } + + @action changeLocale(locale) { + this.currentLocale = locale; + this.intl.setLocale([locale]); + } +} diff --git a/addon/components/tracking-page/map.hbs b/addon/components/tracking-page/map.hbs new file mode 100644 index 000000000..5a0fc6b8d --- /dev/null +++ b/addon/components/tracking-page/map.hbs @@ -0,0 +1,44 @@ +
+ {{@label}} + + + {{#if this.hasRoute}} + + {{else if this.hasPlannedRoute}} + + {{/if}} + {{#each this.pickups as |pickup|}} + + {{pickup.label}} + + {{/each}} + {{#each this.stops as |stop|}} + + {{stop.label}} + + {{/each}} + {{#if this.vehicle}} + + {{this.vehicleLabel}} + + {{/if}} + +
+ {{#if @stale}} + {{@staleText}} + {{else if @live}} + {{t "tracking-page.page.live"}} + {{else}} + {{t "tracking-page.page.map"}} + {{/if}} +
+
+ {{#if this.vehicle}} + + {{/if}} + + +
+
diff --git a/addon/components/tracking-page/map.js b/addon/components/tracking-page/map.js new file mode 100644 index 000000000..04755098e --- /dev/null +++ b/addon/components/tracking-page/map.js @@ -0,0 +1,328 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { next } from '@ember/runloop'; +import { icon } from '@fortawesome/fontawesome-svg-core'; +import { faStore, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { decodePolyline } from '../../utils/tracking-live-source'; + +/** + * Marker colours, as the console's order maps use them. + */ +const PICKUP_COLOR = '#22C55E'; +const DROPOFF_COLOR = '#EF4444'; +const DONE_COLOR = '#6B7280'; + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); +} + +/** + * The live map on the tracking page: where the order is collected, the customer's stops, + * numbered as the stop cards are, and the vehicle while it is on its way to them. Each + * marker carries a label, so the map reads without a legend. + * + * The road line is the tracker's: from the vehicle once it reports, from the first + * pickup before that, sent only when every stop on it is the customer's. Without one, a + * dashed line joins the points already on the map in trip order, which gives away + * nothing the markers don't. + */ +export default class TrackingPageMapComponent extends Component { + @service intl; + + @tracked expanded = false; + + map = null; + element = null; + + get data() { + return this.args.data ?? {}; + } + + get vehicle() { + const location = this.data.location; + + return location ? [location.latitude, location.longitude] : null; + } + + get vehicleLabel() { + return this.data.driver?.name || this.intl.t('tracking-page.page.map-driver'); + } + + get hasSeveralStops() { + return (this.data.stops ?? []).length > 1; + } + + get stops() { + const stops = []; + let number = 0; + for (const stop of this.data.stops ?? []) { + number += 1; + if (stop.place?.latitude && stop.place?.longitude) { + const selected = this.hasSeveralStops && stop.id === this.args.selectedStopId; + const dimmed = this.hasSeveralStops && !!this.args.selectedStopId && !selected; + stops.push({ + ...stop, + number, + location: [stop.place.latitude, stop.place.longitude], + label: stop.place.name || this.intl.t('tracking-page.page.your-stop', { number }), + icon: this.stopIcon(stop.marker ?? String(number), stop.complete, selected, dimmed), + }); + } + } + + return stops; + } + + get pickups() { + return (this.data.map?.pickups ?? []).map((pickup) => ({ + ...pickup, + location: [pickup.latitude, pickup.longitude], + label: pickup.name || this.intl.t('tracking-page.page.map-pickup'), + title: pickup.name ? this.intl.t('tracking-page.page.map-pickup-named', { name: pickup.name }) : this.intl.t('tracking-page.page.map-pickup'), + icon: this.pickupIcon(pickup), + })); + } + + get route() { + return decodePolyline(this.data.map?.polyline); + } + + get hasRoute() { + return this.route.length > 1; + } + + /** + * The vehicle, or the first store still to visit, then the pickups and stops still to + * come. + */ + get plannedRoute() { + const points = this.vehicle ? [this.vehicle] : []; + for (const pickup of this.pickups) { + if (!pickup.complete) { + points.push(pickup.location); + } + } + + for (const stop of this.stops) { + if (!stop.complete) { + points.push(stop.location); + } + } + + return points; + } + + get hasPlannedRoute() { + return !this.hasRoute && this.plannedRoute.length > 1; + } + + get points() { + const points = [...this.pickups.map((pickup) => pickup.location), ...this.stops.map((stop) => stop.location)]; + if (this.vehicle) { + points.push(this.vehicle); + } + + return points; + } + + get center() { + return this.vehicle ?? this.points[0] ?? [0, 0]; + } + + get vehicleIcon() { + return L.divIcon({ + className: this.args.stale ? 'tp-map-marker tp-map-vehicle tp-map-vehicle-stale' : 'tp-map-marker tp-map-vehicle', + html: '', + iconSize: [28, 28], + iconAnchor: [14, 14], + tooltipAnchor: [0, -16], + }); + } + + /** + * A round marker with a label, as the console's order maps draw them. + */ + markerIcon(content, color, classes = []) { + return L.divIcon({ + className: ['tp-map-marker', 'tp-map-point', ...classes].join(' '), + html: `${content}`, + iconSize: [30, 30], + iconAnchor: [15, 15], + tooltipAnchor: [0, -17], + }); + } + + /** + * P, or the waypoint's number; a storefront store shows its logo, or a store. + */ + pickupIcon(pickup) { + if (pickup.complete) { + return this.markerIcon(icon(faCheck).html[0], DONE_COLOR, ['tp-map-point-complete']); + } + + if (pickup.store?.logo_url) { + return this.markerIcon(``, PICKUP_COLOR, ['tp-map-point-logo']); + } + + return this.markerIcon(pickup.store ? icon(faStore).html[0] : escapeHtml(pickup.marker ?? 'P'), PICKUP_COLOR); + } + + stopIcon(marker, complete, selected, dimmed) { + const classes = []; + if (complete) { + classes.push('tp-map-point-complete'); + } + + if (selected) { + classes.push('tp-map-point-selected'); + } + + if (dimmed) { + classes.push('tp-map-point-dimmed'); + } + + const color = complete ? DONE_COLOR : marker === 'D' ? DROPOFF_COLOR : 'var(--tp-accent)'; + + return this.markerIcon(complete ? icon(faCheck).html[0] : escapeHtml(marker), color, classes); + } + + /* ── Labels ────────────────────────────────────────────────────── */ + + /** + * Labels sit above their markers. One that would overlap a label already placed, in + * the order the map gives them priority (the vehicle, the customer's stops, then the + * pickups), moves below its marker instead. + */ + @action placeLabels() { + if (!this.element) { + return; + } + + const labels = ['.tp-map-label-vehicle', '.tp-map-label-stop', '.tp-map-label-pickup'].flatMap((selector) => [...this.element.querySelectorAll(selector)]); + const placed = []; + const overlaps = (box) => placed.some((other) => box.left < other.right + 4 && box.right > other.left - 4 && box.top < other.bottom + 2 && box.bottom > other.top - 2); + for (const label of labels) { + label.classList.remove('tp-map-label-below'); + label.style.top = ''; + let box = label.getBoundingClientRect(); + if (overlaps(box)) { + // From above the marker (its centre less 23px) to below it (its centre + // plus 21px): the label's height and the marker's 30px, with the gaps. + label.classList.add('tp-map-label-below'); + label.style.top = `${Math.round(box.height + 44)}px`; + box = label.getBoundingClientRect(); + } + + placed.push(box); + } + } + + scheduleLabels() { + next(() => { + if (!this.isDestroyed) { + this.placeLabels(); + } + }); + } + + /* ── Full screen ───────────────────────────────────────────────── */ + + get fullscreenLabel() { + return this.intl.t(this.expanded ? 'tracking-page.page.map-exit-fullscreen' : 'tracking-page.page.map-fullscreen'); + } + + @action register(element) { + this.element = element; + document.addEventListener('fullscreenchange', this.onFullscreenChange); + document.addEventListener('keydown', this.onKeydown); + } + + willDestroy() { + super.willDestroy(...arguments); + this.map?.off('zoomend moveend', this.placeLabels); + document.removeEventListener('fullscreenchange', this.onFullscreenChange); + document.removeEventListener('keydown', this.onKeydown); + } + + /** + * The browser's full screen where it has one; otherwise the map covers the window. + */ + @action toggleFullscreen() { + if (document.fullscreenElement === this.element) { + document.exitFullscreen?.(); + return; + } + + if (this.expanded) { + this.setExpanded(false); + return; + } + + const request = this.element?.requestFullscreen?.(); + if (request) { + request.catch(() => this.setExpanded(true)); + } else { + this.setExpanded(true); + } + } + + @action onFullscreenChange() { + this.setExpanded(!!this.element && document.fullscreenElement === this.element); + } + + @action onKeydown(event) { + if (event.key === 'Escape' && this.expanded && !document.fullscreenElement) { + this.setExpanded(false); + } + } + + setExpanded(expanded) { + this.expanded = expanded; + next(() => { + if (this.map && !this.isDestroyed) { + this.map.invalidateSize(); + this.fitRoute(); + } + }); + } + + /* ── Map ───────────────────────────────────────────────────────── */ + + @action setup({ target }) { + this.map = target; + this.map.on('zoomend moveend', this.placeLabels); + this.fitRoute(); + this.scheduleLabels(); + } + + @action fitRoute() { + if (!this.map || this.points.length === 0) { + return; + } + + if (this.points.length === 1) { + this.map.setView(this.points[0], 14); + return; + } + + this.map.fitBounds(L.latLngBounds(this.points), { padding: [48, 48], maxZoom: 15 }); + } + + @action recenter() { + if (this.map && this.vehicle) { + this.map.setView(this.vehicle, 15, { animate: !this.args.reducedMotion }); + } + } + + /** + * Bring the stop the customer picked into view. + */ + @action focusStop() { + const stop = this.stops.find((candidate) => candidate.id === this.args.selectedStopId); + if (this.map && stop) { + this.map.panTo(stop.location, { animate: !this.args.reducedMotion }); + } + } +} diff --git a/addon/components/tracking-page/order-list.hbs b/addon/components/tracking-page/order-list.hbs new file mode 100644 index 000000000..cb82592ba --- /dev/null +++ b/addon/components/tracking-page/order-list.hbs @@ -0,0 +1,35 @@ +
+
+ {{t "tracking-page.page.chip-account"}} +

{{t "tracking-page.page.your-orders"}}

+
+ {{#if @isLoading}} +
+
+
+
+
+ {{else if @orders.length}} +
    + {{#each @orders as |order|}} +
  • + +
  • + {{/each}} +
+ {{else}} +
+ +
{{t "tracking-page.page.no-orders"}}
+
{{t "tracking-page.page.no-orders-help"}}
+
+ {{/if}} +
diff --git a/addon/components/tracking-page/public-view.hbs b/addon/components/tracking-page/public-view.hbs new file mode 100644 index 000000000..1c09d0d51 --- /dev/null +++ b/addon/components/tracking-page/public-view.hbs @@ -0,0 +1,60 @@ +
+ {{t "tracking-page.page.chip-limited"}} + +
+ {{#if @data.stage}} +
+
{{t "tracking-page.page.status"}}
+

{{t (concat "tracking-page.page.stage-" @data.stage)}}

+ {{#if @data.last_update_on}} +
{{t "tracking-page.page.last-update" date=@data.last_update_on}}
+ {{/if}} +
+ + {{else}} +

{{t "tracking-page.page.verify-to-see"}}

+ {{/if}} +
+
+ {{t "tracking-page.page.tracking-number"}} + {{@data.tracking_number}} +
+ {{t "tracking-page.page.copy"}} +
+
+ +
+ +
+

{{t "tracking-page.page.verify-title"}}

+

{{t "tracking-page.page.verify-help"}}

+
+
+ + {{#if @data.sign_in}} + {{t "tracking-page.page.sign-in-account"}} + {{/if}} +
+
+ +
+ {{#each (array "map" "arrival" "details") as |section|}} +
+ +
+ + {{t (concat "tracking-page.page.locked-" section)}} + +
+
+ {{/each}} +
+
diff --git a/addon/components/tracking-page/settings-preview.hbs b/addon/components/tracking-page/settings-preview.hbs new file mode 100644 index 000000000..94e494435 --- /dev/null +++ b/addon/components/tracking-page/settings-preview.hbs @@ -0,0 +1,21 @@ + diff --git a/addon/components/tracking-page/settings-preview.js b/addon/components/tracking-page/settings-preview.js new file mode 100644 index 000000000..a4d2a5901 --- /dev/null +++ b/addon/components/tracking-page/settings-preview.js @@ -0,0 +1,23 @@ +import Component from '@glimmer/component'; +import { htmlSafe } from '@ember/template'; + +/** + * A small phone-sized preview of the customer tracking page, themed from the settings being edited. + */ +export default class TrackingPageSettingsPreviewComponent extends Component { + get style() { + return htmlSafe(this.args.themeStyle ?? ''); + } + + get monogram() { + const words = (this.args.displayName ?? '').split(/\s+/); + let monogram = ''; + for (const word of words) { + if (word && monogram.length < 2) { + monogram += word[0].toUpperCase(); + } + } + + return monogram; + } +} diff --git a/addon/components/tracking-page/verify-panel.hbs b/addon/components/tracking-page/verify-panel.hbs new file mode 100644 index 000000000..857dbc393 --- /dev/null +++ b/addon/components/tracking-page/verify-panel.hbs @@ -0,0 +1,109 @@ +
+ + + {{#if (or (eq @step "choose") (eq @step "sending"))}} +
+

{{t "tracking-page.page.verify-title-short"}}

+

{{t "tracking-page.page.verify-choose-help"}}

+
+
+ {{t "tracking-page.page.send-by"}} + {{#each @channels as |channel|}} + + {{/each}} +
+ {{#if (eq @message "failed")}} + + {{/if}} + +

{{t "tracking-page.page.masked-note"}}

+ {{else if (eq @step "none")}} +
+

{{t "tracking-page.page.no-contact-title"}}

+

{{t "tracking-page.page.no-contact-help" name=@company.name}}

+
+ {{#if @company.support_phone}} + {{t "tracking-page.page.call" phone=@company.support_phone}} + {{/if}} + {{#if @company.support_email}} + {{@company.support_email}} + {{/if}} + {{else if (eq @step "renew")}} +
+

{{t "tracking-page.page.renew-title"}}

+

{{t (concat "tracking-page.page.renew-" (or @message "expired"))}}

+
+ + {{else}} +
+

+ {{#if (eq @step "success")}}{{t "tracking-page.page.verified-title"}}{{else if (eq @step "locked")}}{{t "tracking-page.page.locked-title"}}{{else}}{{t "tracking-page.page.enter-code"}}{{/if}} +

+

{{t (concat "tracking-page.page.code-sent-" @selectedChannel) masked=@masked}}

+
+
+ + + +
+
+ {{#if (eq @step "sent")}} +

{{t "tracking-page.page.code-expires"}}

+ {{else if (eq @step "wrong")}} +

{{t "tracking-page.page.code-wrong" count=@attemptsLeft}}

+ {{else if (eq @step "locked")}} +

{{t "tracking-page.page.code-locked" time=@lockText}}

+ {{else if (eq @step "success")}} +

{{t "tracking-page.page.code-success"}}

+ {{/if}} +
+ {{#if (or (eq @step "sent") (eq @step "wrong"))}} +
+ {{#if @cooldown}} + {{t "tracking-page.page.resend-in" time=@cooldownText}} + {{else}} + + {{/if}} + {{#if @otherChannel}} + + {{/if}} +
+ {{/if}} + {{/if}} + +

{{t "tracking-page.page.stay-verified" hours=(or @sessionHours 24)}}

+
diff --git a/addon/components/trailer/form.hbs b/addon/components/trailer/form.hbs index 973986e5a..436103a52 100644 --- a/addon/components/trailer/form.hbs +++ b/addon/components/trailer/form.hbs @@ -389,7 +389,7 @@ - {{yield resource}} - diff --git a/addon/components/vehicle/summary.hbs b/addon/components/vehicle/summary.hbs deleted file mode 100644 index 577d3938c..000000000 --- a/addon/components/vehicle/summary.hbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/addon/components/vendor/pill.hbs b/addon/components/vendor/pill.hbs deleted file mode 100644 index 4fcf04ef5..000000000 --- a/addon/components/vendor/pill.hbs +++ /dev/null @@ -1,24 +0,0 @@ - - {{yield resource}} - diff --git a/addon/components/vendor/summary.hbs b/addon/components/vendor/summary.hbs deleted file mode 100644 index c231423b0..000000000 --- a/addon/components/vendor/summary.hbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/addon/components/warranty/form.hbs b/addon/components/warranty/form.hbs index 094a6d171..2b1ffb903 100644 --- a/addon/components/warranty/form.hbs +++ b/addon/components/warranty/form.hbs @@ -29,7 +29,7 @@ Map), else null. + */ + get vehicleBounds() { + if (!this.mapSettings.fitMapToVehicles) return null; + return positionedBounds(this.vehicles.map((vehicle) => ({ location: [vehicle.lat, vehicle.lng] }))); + } + + /** + * Where the map opens, in order: the positioned vehicles (when fitting is + * on), the company's default centre, the first positioned marker, the + * centre of the company's country, the hardcoded default. + */ + get initialView() { + const bounds = this.vehicleBounds; + if (bounds) { + const [[south, west], [north, east]] = bounds; + return { center: [(south + north) / 2, (west + east) / 2], zoom: DEFAULT_ZOOM, bounds }; + } + + const defaultCenter = this.mapSettings.defaultMapCenter; + if (defaultCenter) return { center: [defaultCenter.latitude, defaultCenter.longitude], zoom: defaultCenter.zoom }; + const first = this.drivers[0] ?? this.vehicles[0]; - if (first?.lat && first?.lng) return [first.lat, first.lng]; - return DEFAULT_CENTER; + if (first?.lat && first?.lng) return { center: [first.lat, first.lng], zoom: DEFAULT_ZOOM }; + + const countryCenter = this.mapSettings.companyCountryCenter; + if (countryCenter) return { center: [countryCenter.latitude, countryCenter.longitude], zoom: countryCenter.zoom }; + + return { center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM }; + } + + get center() { + return this.initialView.center; } get zoom() { - return DEFAULT_ZOOM; + return this.initialView.zoom; + } + + /** Frame the positioned vehicles once, after the first data load. */ + fitToVehicles(map) { + if (this.#fittedToVehicles) return false; + + const bounds = this.vehicleBounds; + if (!bounds || typeof map?.fitBounds !== 'function') return false; + + map.fitBounds(bounds, { padding: [30, 30], maxZoom: FIT_VEHICLES_MAX_ZOOM }); + this.#fittedToVehicles = true; + return true; } statusColor(status) { @@ -182,9 +227,12 @@ export default class WidgetLiveFleetComponent extends Component { // container before we add markers. Without this, markers added to a // not-yet-sized map silently no-op (the data arrives, count chip ticks // up, but nothing paints). - yield this.ensureInteractive(); + const map = yield this.ensureInteractive(); this.data = yield this.fetch.get('fleet-ops/analytics/live-fleet'); this.error = null; + // The fit choice lives in the company's map settings, loaded in the constructor. + yield this.mapSettings.load(); + this.fitToVehicles(map); } catch (e) { this.error = e?.message ?? 'Failed to load live fleet'; } diff --git a/addon/components/widget/proof-of-delivery.hbs b/addon/components/widget/proof-of-delivery.hbs new file mode 100644 index 000000000..696b8c3a2 --- /dev/null +++ b/addon/components/widget/proof-of-delivery.hbs @@ -0,0 +1,49 @@ +
+
+ {{t "widget.proof-of-delivery.title"}} + +
+ +
+ {{#if this.load.isRunning}} +
+ {{else if this.error}} +
{{this.error}}
+ {{else if this.photos.length}} +
+ {{#each this.photos as |proof|}} +
+ +
+ {{#if proof.order_id}} + {{proof.order_id}} + {{/if}} + {{#if proof.timeAgo}} +
{{proof.timeAgo}}
+ {{/if}} +
+
+ {{/each}} +
+ {{else}} +
+ +
{{t "widget.proof-of-delivery.empty"}}
+
+ {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/widget/proof-of-delivery.js b/addon/components/widget/proof-of-delivery.js new file mode 100644 index 000000000..1d68d2f6d --- /dev/null +++ b/addon/components/widget/proof-of-delivery.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { getOwner } from '@ember/application'; +import { task } from 'ember-concurrency'; +import { formatDistanceToNowStrict, isValid } from 'date-fns'; +import config from '../../config/environment'; + +/** + * The latest proof of delivery photos across the fleet, each linking to its order. + */ +export default class WidgetProofOfDeliveryComponent extends Component { + static widgetId = 'fleet-ops-proof-of-delivery-widget'; + static limit = 12; + + @service fetch; + + @tracked proofs = []; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get photos() { + return (this.proofs ?? []) + .filter((proof) => proof?.url) + .map((proof) => ({ + ...proof, + timeAgo: this.timeAgo(proof.captured_at), + })); + } + + get orderRoute() { + // Dashboard widgets render under the host owner, outside the engine's routing context. + return getOwner(this).mountPoint ? 'operations.orders.index.details' : `${config.mountedEngineRoutePrefix}.operations.orders.index.details`; + } + + timeAgo(capturedAt) { + const date = capturedAt ? new Date(capturedAt) : null; + + return date && isValid(date) ? formatDistanceToNowStrict(date, { addSuffix: true }) : null; + } + + @action openPhoto(proof) { + if (proof?.url) { + window.open(proof.url, '_blank', 'noopener,noreferrer'); + } + } + + @task({ restartable: true }) *load() { + this.error = null; + try { + const response = yield this.fetch.get('fleet-ops/proofs/recent', { limit: WidgetProofOfDeliveryComponent.limit }); + this.proofs = response?.proofs ?? []; + } catch (err) { + this.error = err?.message ?? 'Failed to load'; + } + } +} diff --git a/addon/components/work-order/form.hbs b/addon/components/work-order/form.hbs index e5cfc85a7..fa4cc5085 100644 --- a/addon/components/work-order/form.hbs +++ b/addon/components/work-order/form.hbs @@ -175,7 +175,7 @@ { if (reloadable) { - await this.hostRouter.refresh(); + await this.refreshDetails(); } }, { debounceMs: 250 } diff --git a/addon/controllers/operations/scheduler/index.js b/addon/controllers/operations/scheduler/index.js index 1e6ff2bfe..ba91409d8 100644 --- a/addon/controllers/operations/scheduler/index.js +++ b/addon/controllers/operations/scheduler/index.js @@ -4,6 +4,7 @@ import { inject as service } from '@ember/service'; import { action, computed } from '@ember/object'; import { isNone } from '@ember/utils'; import { isValid as isValidDate } from 'date-fns'; +import { later } from '@ember/runloop'; import { task } from 'ember-concurrency'; import isObject from '@fleetbase/ember-core/utils/is-object'; import isJson from '@fleetbase/ember-core/utils/is-json'; @@ -11,6 +12,9 @@ import createFullCalendarEventFromOrder from '../../../utils/create-full-calenda import createFullCalendarEventFromScheduleItem from '../../../utils/create-full-calendar-event-from-schedule-item'; import toCalendarDate from '../../../utils/to-calendar-date'; +// The ember-core socket service subscribes ~300 ms after listen() is called. +const SOCKET_SUBSCRIBE_SETTLE_MS = 500; + /** * OperationsSchedulerIndexController * @@ -722,28 +726,55 @@ export default class OperationsSchedulerIndexController extends Controller { // Real-Time Socket Subscriptions // ------------------------------------------------------------------------- + // Names of the socket channels this board opened, so teardown closes only those + // and leaves every other subscription (chat, notifications, ...) alone. + _socketChannelNames = new Set(); + @action async subscribeToRealTimeUpdates() { - const orgId = this.currentUser?.companyId ?? this.currentUser?.company?.id; - if (!orgId) return; - await this.socket.listen(`company.${orgId}.orders`, (payload) => this._handleOrderSocketEvent(payload)); - this.drivers.forEach(async (driver) => { - await this.socket.listen(`driver.${driver.id}`, (payload) => this._handleDriverSocketEvent(payload)); - }); + const drivers = this.drivers ?? []; + await Promise.all( + drivers.map((driver) => { + if (!driver?.id) return; + const channelName = `driver.${driver.id}`; + if (this._socketChannelNames.has(channelName)) return; + this._socketChannelNames.add(channelName); + return this.socket.listen(channelName, (payload) => this._handleDriverSocketEvent(payload)); + }) + ); } @action unsubscribeFromRealTimeUpdates() { - if (this.socket && typeof this.socket.closeChannels === 'function') { - this.socket.closeChannels(); + const names = this._socketChannelNames; + this._socketChannelNames = new Set(); + if (!this.socket || names.size === 0) return; + + if (typeof this.socket.closeChannel === 'function') { + names.forEach((name) => this.socket.closeChannel(name)); + return; } + + const remaining = this._closeOpenedChannels(names); + if (remaining.size === 0) return; + + // The socket service subscribes after a short delay, so a channel requested just before + // teardown may not exist yet: sweep once more after that delay. + later(this, () => this._closeOpenedChannels(remaining), SOCKET_SUBSCRIBE_SETTLE_MS); } - _handleOrderSocketEvent({ data } = {}) { - if (!data?.id) return; - try { - this.store.pushPayload('order', { order: data }); - } catch { - /* ignore */ + /** + * Close the socket's channels with the given names, except any the board has opened again + * since. Returns the names that had no channel yet. + */ + _closeOpenedChannels(names) { + const remaining = new Set([...names].filter((name) => !this._socketChannelNames.has(name))); + const channels = Array.isArray(this.socket.channels) ? this.socket.channels : []; + for (const channel of channels) { + if (!names.has(channel?.name) || this._socketChannelNames.has(channel.name)) continue; + remaining.delete(channel.name); + channel.close(); } + + return remaining; } _handleDriverSocketEvent({ event, data } = {}) { diff --git a/addon/controllers/settings/index.js b/addon/controllers/settings/index.js index 586754c08..195fa5f19 100644 --- a/addon/controllers/settings/index.js +++ b/addon/controllers/settings/index.js @@ -28,6 +28,12 @@ export default class SettingsIndexController extends Controller { description: 'Keep commerce, metadata, and visual conventions aligned.', links: [ { label: 'Payments', route: 'settings.payments', icon: 'cash-register', description: 'Payment setup for operational commerce workflows.' }, + { + label: 'Tracking Page', + route: 'settings.tracking-page', + icon: 'magnifying-glass-location', + description: 'The page your customers use to follow deliveries: address, branding and how they verify.', + }, { label: 'Telematics', route: 'settings.telematics', diff --git a/addon/controllers/settings/map.js b/addon/controllers/settings/map.js index 7639bdf3d..dada0fc60 100644 --- a/addon/controllers/settings/map.js +++ b/addon/controllers/settings/map.js @@ -4,6 +4,7 @@ import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; import MAP_PROVIDER_OPTIONS from '../../utils/map-provider-options'; +import { isLatitude, isLongitude, isZoom, toCoordinate } from '../../utils/map-default-view'; export default class SettingsMapController extends Controller { @service fetch; @@ -11,6 +12,7 @@ export default class SettingsMapController extends Controller { @service intl; @service mapManager; @service mapSettings; + @service location; /** * Available map provider options for the provider selector. @@ -39,6 +41,21 @@ export default class SettingsMapController extends Controller { @tracked showGoogleMapsTrafficLayer = false; @tracked showGoogleMapsTransitLayer = false; + /** + * The company's default live map view. Latitude and longitude are both + * set or both null; zoom is optional. + */ + @tracked defaultMapLatitude = null; + @tracked defaultMapLongitude = null; + @tracked defaultMapZoom = null; + @tracked fitMapToVehicles = false; + + /** + * Bumped to re-create the coordinates input, which only reads its value + * when it is constructed (e.g. after clearing the default center). + */ + @tracked defaultCenterInputKey = 0; + /** * Whether settings have been loaded from the server. * @@ -74,6 +91,36 @@ export default class SettingsMapController extends Controller { return this.mapProvider === 'leaflet'; } + /** + * The default center as a GeoJSON point for the coordinates input, or null. + * + * @memberof SettingsMapController + * @return {Object|null} + */ + get defaultMapCenterPoint() { + // Not null: CoordinatesInput#isPoint reads `.type` on any object, and typeof null is "object". + if (!this.hasDefaultMapCenter) { + return undefined; + } + + return { type: 'Point', coordinates: [this.defaultMapLongitude, this.defaultMapLatitude] }; + } + + get hasDefaultMapCenter() { + return isLatitude(this.defaultMapLatitude) && isLongitude(this.defaultMapLongitude); + } + + /** + * The zoom to save: a whole number from 1 to 20, else null. + * + * @memberof SettingsMapController + * @return {Number|null} + */ + get normalizedDefaultMapZoom() { + const zoom = toCoordinate(this.defaultMapZoom); + return isZoom(zoom) ? zoom : null; + } + // ─── Actions ─────────────────────────────────────────────────────────────── /** @@ -86,6 +133,24 @@ export default class SettingsMapController extends Controller { this.mapProvider = option?.value ?? option ?? 'leaflet'; } + /** + * Receives coordinates typed into, or picked on the map of, the default + * center input. + * + * @param {{latitude: *, longitude: *}} coordinates + * @memberof SettingsMapController + */ + @action setDefaultMapCenter({ latitude, longitude } = {}) { + this.defaultMapLatitude = toCoordinate(latitude); + this.defaultMapLongitude = toCoordinate(longitude); + } + + @action clearDefaultMapCenter() { + this.defaultMapLatitude = null; + this.defaultMapLongitude = null; + this.defaultCenterInputKey++; + } + @task *saveSettings() { const settings = { mapProvider: this.mapProvider, @@ -94,12 +159,19 @@ export default class SettingsMapController extends Controller { googleMapsMapType: this.googleMapsMapType, showGoogleMapsTrafficLayer: this.showGoogleMapsTrafficLayer, showGoogleMapsTransitLayer: this.showGoogleMapsTransitLayer, + defaultMapLatitude: this.hasDefaultMapCenter ? this.defaultMapLatitude : null, + defaultMapLongitude: this.hasDefaultMapCenter ? this.defaultMapLongitude : null, + defaultMapZoom: this.normalizedDefaultMapZoom, + fitMapToVehicles: this.fitMapToVehicles, }; try { const response = yield this.fetch.post('fleet-ops/settings/map', { settings }); this.mapSettings.applySettings(response); + // Re-resolve where maps open so an open live map moves to the new default. + this.location.relocate(); + if (this.mapManager && typeof this.mapManager.setActiveProvider === 'function') { this.mapManager.setActiveProvider(this.mapProvider); } @@ -124,6 +196,9 @@ export default class SettingsMapController extends Controller { this.googleMapsMapType = response?.googleMapsMapType ?? 'roadmap'; this.showGoogleMapsTrafficLayer = Boolean(response?.showGoogleMapsTrafficLayer); this.showGoogleMapsTransitLayer = Boolean(response?.showGoogleMapsTransitLayer); + this.setDefaultMapCenter({ latitude: response?.defaultMapLatitude, longitude: response?.defaultMapLongitude }); + this.defaultMapZoom = response?.defaultMapZoom ?? null; + this.fitMapToVehicles = Boolean(response?.fitMapToVehicles); this.settingsLoaded = true; } catch (error) { this.notifications.serverError(error); diff --git a/addon/controllers/settings/orchestrator.js b/addon/controllers/settings/orchestrator.js index be24bcc69..bb7039f76 100644 --- a/addon/controllers/settings/orchestrator.js +++ b/addon/controllers/settings/orchestrator.js @@ -17,7 +17,8 @@ export default class SettingsOrchestratorController extends Controller { @service intl; @service('orchestration-engine') engineRegistry; - @tracked allocationEngine = 'vroom'; + @tracked allocationEngine = 'greedy'; + @tracked serverEngines = null; @tracked autoAllocateOnCreate = false; @tracked autoReallocateOnComplete = false; @tracked maxTravelTimeSeconds = 3600; @@ -26,18 +27,19 @@ export default class SettingsOrchestratorController extends Controller { /** * Engine options for the selector dropdown. - * Populated from the allocation-engine registry so new engines appear - * automatically without modifying this controller. + * Populated from the engines registered on the server (the ones a run can + * actually use), falling back to the frontend allocation-engine registry. */ get engineOptions() { - return this.engineRegistry.availableEngines; + return this.serverEngines ?? this.engineRegistry.availableEngines; } @task *loadSettings() { this.isLoading = true; try { - const settings = yield this.fetch.get('fleet-ops/settings/orchestrator-settings'); - this.allocationEngine = settings.allocation_engine ?? 'vroom'; + const [settings, engines] = yield Promise.all([this.fetch.get('fleet-ops/settings/orchestrator-settings'), this.fetch.get('fleet-ops/orchestrator/engines').catch(() => null)]); + this.serverEngines = engines?.engines ?? null; + this.allocationEngine = settings.allocation_engine ?? 'greedy'; this.autoAllocateOnCreate = settings.auto_allocate_on_create ?? false; this.autoReallocateOnComplete = settings.auto_reallocate_on_complete ?? false; this.maxTravelTimeSeconds = settings.max_travel_time_seconds ?? 3600; diff --git a/addon/controllers/settings/tracking-page.js b/addon/controllers/settings/tracking-page.js new file mode 100644 index 000000000..abe5cc072 --- /dev/null +++ b/addon/controllers/settings/tracking-page.js @@ -0,0 +1,212 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task, timeout } from 'ember-concurrency'; +import trackingPageTheme, { contrastRatio, inkFor, normalizeHex, DEFAULT_ACCENT } from '../../utils/tracking-page-theme'; + +/** + * The locales a tracking page can be shown in, labelled in their own language. + */ +export const TRACKING_PAGE_LOCALES = [ + { code: 'en-us', label: 'English' }, + { code: 'ar-ae', label: 'العربية' }, + { code: 'bg-bg', label: 'Български' }, + { code: 'es-pa', label: 'Español' }, + { code: 'fr-fr', label: 'Français' }, + { code: 'mn-mn', label: 'Монгол' }, + { code: 'pt-br', label: 'Português' }, + { code: 'ru-ru', label: 'Русский' }, + { code: 'uk-ua', label: 'Українська' }, + { code: 'vi-vn', label: 'Tiếng Việt' }, +]; + +/** + * Keys the server adds to the saved config when it reads it back. They are facts about + * the instance, not settings, so they are kept apart and never sent back on save. + */ +const READ_ONLY_KEYS = ['slug_validation', 'accent_ink', 'accent_contrast', 'sms_available', 'customer_portal_installed', 'admin']; + +/** + * Settings::TrackingPageController + * + * The company's customer tracking page: which pages are on and at what address, branding, + * how customers prove who they are, and what verified customers see. + */ +export default class SettingsTrackingPageController extends Controller { + @service fetch; + @service notifications; + @service intl; + @service currentUser; + + @tracked config = null; + @tracked slugValidation = null; + @tracked smsAvailable = false; + @tracked customerPortalInstalled = false; + @tracked loadFailed = false; + + /** + * Take the settings as the server returns them. + */ + load(settings) { + if (!settings) { + this.loadFailed = true; + return; + } + + const config = { ...settings }; + READ_ONLY_KEYS.forEach((key) => delete config[key]); + + this.config = config; + this.slugValidation = settings.slug_validation ?? null; + this.smsAvailable = settings.sms_available === true; + this.customerPortalInstalled = settings.customer_portal_installed === true; + this.loadFailed = false; + } + + get accent() { + return normalizeHex(this.config?.branding?.accent) ?? DEFAULT_ACCENT; + } + + get accentInk() { + return inkFor(this.accent); + } + + get accentContrast() { + return contrastRatio(this.accent, this.accentInk); + } + + get accentReadable() { + return this.accentContrast >= 4.5; + } + + get previewLightStyle() { + return trackingPageTheme(this.config?.branding, false); + } + + get previewDarkStyle() { + return trackingPageTheme(this.config?.branding, true); + } + + get companyName() { + return this.currentUser.companyName ?? ''; + } + + get displayName() { + return this.config?.branding?.display_name || this.companyName; + } + + get orgPageBase() { + return `${window.location.origin}/~/t/`; + } + + get orgPageUrl() { + return `${window.location.origin}/~/t/${encodeURIComponent(this.config?.org_page?.slug ?? '')}`; + } + + get genericPageUrl() { + return `${window.location.origin}/~/track`; + } + + get slugIsValid() { + return this.slugValidation?.valid === true; + } + + get localeOptions() { + const enabled = this.config?.branding?.locales ?? []; + const defaultLocale = this.config?.branding?.default_locale; + + return TRACKING_PAGE_LOCALES.map((locale) => ({ + ...locale, + enabled: enabled.includes(locale.code), + isDefault: locale.code === defaultLocale, + })); + } + + get linkTargetOptions() { + return ['auto', 'org', 'generic'].map((value) => ({ value, label: this.intl.t(`tracking-page.settings.links-target-${value}`) })); + } + + get themeOptions() { + return ['system', 'light'].map((value) => ({ value, label: this.intl.t(`tracking-page.settings.theme-${value}`) })); + } + + get driverContactOptions() { + return ['company', 'driver'].map((value) => ({ value, label: this.intl.t(`tracking-page.settings.driver-contact-${value}`) })); + } + + /** + * Set one value by its dotted path, replacing the config so the form and preview update. + */ + @action update(path, value) { + const config = JSON.parse(JSON.stringify(this.config)); + const keys = path.split('.'); + const last = keys.pop(); + let target = config; + for (const key of keys) { + target = target[key]; + } + + target[last] = value; + this.config = config; + } + + @action updateFromEvent(path, event) { + this.update(path, event.target.value); + } + + @action updateNumber(path, event) { + this.update(path, parseInt(event.target.value, 10)); + } + + @action selectOption(path, option) { + this.update(path, option?.value ?? option); + } + + @action updateSlug(event) { + const slug = event.target.value.toLowerCase().replace(/\s+/g, '-'); + this.update('org_page.slug', slug); + this.validateSlug.perform(slug); + } + + @action toggleLocale(code) { + const locales = this.config.branding.locales ?? []; + if (code === this.config.branding.default_locale) { + return; + } + + this.update('branding.locales', locales.includes(code) ? locales.filter((locale) => locale !== code) : [...locales, code]); + } + + @action setDefaultLocale(option) { + const code = option?.value ?? option; + const locales = this.config.branding.locales ?? []; + this.update('branding.default_locale', code); + if (!locales.includes(code)) { + this.update('branding.locales', [...locales, code]); + } + } + + get defaultLocaleOptions() { + return TRACKING_PAGE_LOCALES.map((locale) => ({ value: locale.code, label: locale.label })); + } + + @task({ restartable: true }) *validateSlug(slug) { + yield timeout(350); + try { + this.slugValidation = yield this.fetch.post('fleet-ops/settings/tracking-page-settings/validate-slug', { slug }); + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *saveSettings() { + try { + const settings = yield this.fetch.post('fleet-ops/settings/tracking-page-settings', { trackingPage: this.config }); + this.load(settings); + this.notifications.success(this.intl.t('tracking-page.settings.saved')); + } catch (error) { + this.notifications.serverError(error); + } + } +} diff --git a/addon/extension.js b/addon/extension.js index 0427f7d69..a4bd25358 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -117,12 +117,34 @@ export default { icon: 'satellite-dish', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'admin/telematics-settings'), }), + new MenuItem({ + title: 'Tracking Page', + icon: 'magnifying-glass-location', + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'admin/tracking-page-settings'), + }), ], { slug: 'fleet-ops', } ); + // Links printed or emailed without the public `~/` prefix (/track-order?order=...) land + // inside the console. Signed-out visitors are sent on to the public route after login; + // registering the page in the console too shows it to signed-in staff instead of an + // empty console page. + for (const slug of ['track', 'track-order']) { + menuService.registerMenuItem( + 'console', + new MenuItem({ + title: 'Track Order', + slug, + section: null, + view: null, + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), + }) + ); + } + // Register track order button menuService.registerMenuItem( 'auth:login', @@ -135,7 +157,39 @@ export default { // btn-auth (ember-ui): the sign-in page's neutral button, matching the // console's "Continue with ..." provider buttons, hover included. wrapperClass: 'btn-block btn-auth', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'order-tracking-lookup'), + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), + onClick: (menuItem) => { + universe.transitionMenuItem('virtual', menuItem); + }, + }) + ); + + // The customer tracking page: /~/track/{number} is the shared page. + menuService.registerMenuItem( + 'auth:login', + new MenuItem({ + title: 'Track', + route: 'virtual', + slug: 'track', + type: 'link', + wrapperClass: 'hidden', + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), + onClick: (menuItem) => { + universe.transitionMenuItem('virtual', menuItem); + }, + }) + ); + + // A company's own tracking page: /~/t/{slug}/{number}. + menuService.registerMenuItem( + 'auth:login', + new MenuItem({ + title: 'Company tracking', + route: 'virtual', + slug: 't', + type: 'link', + wrapperClass: 'hidden', + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), onClick: (menuItem) => { universe.transitionMenuItem('virtual', menuItem); }, @@ -413,6 +467,16 @@ export default { order: 80, default: true, }), + new Widget({ + id: 'fleet-ops-proof-of-delivery-widget', + name: 'Proof of Delivery', + description: 'The latest proof of delivery photos, each linking to its order.', + icon: 'camera', + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/proof-of-delivery'), + grid_options: { w: 4, h: 9, minW: 3, minH: 6 }, + category: 'Operations', + default: false, + }), new Widget({ id: 'fleet-ops-geofence-violations-widget', name: 'Geofence Violations', diff --git a/addon/helpers/parcel-box-image.js b/addon/helpers/parcel-box-image.js new file mode 100644 index 000000000..5d28d8f53 --- /dev/null +++ b/addon/helpers/parcel-box-image.js @@ -0,0 +1,16 @@ +import { helper } from '@ember/component/helper'; + +// Each URL is written out in full so a production build's fingerprinting +// (broccoli-asset-rev) rewrites it to the hashed file name. An interpolated +// path such as `boxes/${size}.png` is never rewritten, so it misses the +// fingerprinted file and the server answers with the console's index.html. +const PARCEL_BOX_IMAGES = { + small: '/engines-dist/images/boxes/small.png', + medium: '/engines-dist/images/boxes/medium.png', + large: '/engines-dist/images/boxes/large.png', + 'x-large': '/engines-dist/images/boxes/x-large.png', +}; + +export default helper(function parcelBoxImage([size]) { + return PARCEL_BOX_IMAGES[size] ?? PARCEL_BOX_IMAGES.medium; +}); diff --git a/addon/routes.js b/addon/routes.js index 9596e2885..5dc216772 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -323,6 +323,7 @@ export default buildRoutes(function () { this.route('map'); this.route('orchestrator'); this.route('scheduling'); + this.route('tracking-page'); this.route('telematics'); this.route('payments', function () { this.route('index', { path: '/' }); diff --git a/addon/routes/operations/orders/index/new.js b/addon/routes/operations/orders/index/new.js index e8302b75e..91cdb3d9c 100644 --- a/addon/routes/operations/orders/index/new.js +++ b/addon/routes/operations/orders/index/new.js @@ -1,6 +1,5 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { action } from '@ember/object'; export default class OperationsOrdersIndexNewRoute extends Route { @service notifications; @@ -9,9 +8,11 @@ export default class OperationsOrdersIndexNewRoute extends Route { @service intl; @service sidebar; - @action willTransition() { - if (this.controller) { - this.controller.reset(); + // Reset the draft only when leaving the route. A refresh of this route (e.g. after saving + // a place or customer from a modal) also transitions, and must keep what was entered. + resetController(controller, isExiting) { + if (isExiting) { + controller.reset(); } } diff --git a/addon/routes/operations/scheduler/index.js b/addon/routes/operations/scheduler/index.js index 8f8f1d7d4..5f3f76d4f 100644 --- a/addon/routes/operations/scheduler/index.js +++ b/addon/routes/operations/scheduler/index.js @@ -61,7 +61,7 @@ export default class OperationsSchedulerIndexRoute extends Route { } resetController(controller) { - // Close all socket channels when the dispatcher navigates away + // Close the board's own socket channels when the dispatcher navigates away // to prevent memory leaks and stale event handlers. controller.unsubscribeFromRealTimeUpdates(); } diff --git a/addon/routes/settings/tracking-page.js b/addon/routes/settings/tracking-page.js new file mode 100644 index 000000000..adb5049f3 --- /dev/null +++ b/addon/routes/settings/tracking-page.js @@ -0,0 +1,31 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class SettingsTrackingPageRoute extends Route { + @service notifications; + @service hostRouter; + @service abilities; + @service intl; + @service fetch; + + beforeModel() { + if (this.abilities.cannot('fleet-ops view tracking-page-settings')) { + this.notifications.warning(this.intl.t('common.unauthorized-access')); + return this.hostRouter.transitionTo('console.fleet-ops'); + } + } + + async model() { + try { + return await this.fetch.get('fleet-ops/settings/tracking-page-settings'); + } catch (error) { + this.notifications.serverError(error); + return null; + } + } + + setupController(controller, model) { + super.setupController(controller, model); + controller.load(model); + } +} diff --git a/addon/services/location.js b/addon/services/location.js index ed2c3c737..f44245a92 100644 --- a/addon/services/location.js +++ b/addon/services/location.js @@ -4,7 +4,7 @@ import { isBlank } from '@ember/utils'; import { isArray } from '@ember/array'; import { later } from '@ember/runloop'; import { debug } from '@ember/debug'; -import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; +import { isLatitude, isLongitude, isZoom } from '../utils/map-default-view'; /** * Service for managing and retrieving user location data. @@ -46,17 +46,30 @@ export default class LocationService extends Service { */ @service fetch; + /** + * Company map settings, which carry the default map view. + * @type {MapSettingsService} + */ + @service mapSettings; + /** * Current latitude of the user. * @type {number} */ - @tracked latitude = this.DEFAULT_LATITUDE; + @tracked latitude = LocationService.DEFAULT_LATITUDE; /** * Current longitude of the user. * @type {number} */ - @tracked longitude = this.DEFAULT_LONGITUDE; + @tracked longitude = LocationService.DEFAULT_LONGITUDE; + + /** + * Zoom suggested by the source of the location (a company default view or + * a country centre), or null to let the map use its own default. + * @type {number|null} + */ + @tracked zoom = null; /** * Flag indicating whether the user's location has been located. @@ -73,7 +86,7 @@ export default class LocationService extends Service { if (typeof this.latitude === 'number' && !isNaN(this.latitude) && this.latitude >= -90 && this.latitude <= 90) { return this.latitude; } - return this.constructor.DEFAULT_LATITUDE; + return LocationService.DEFAULT_LATITUDE; } /** @@ -85,14 +98,23 @@ export default class LocationService extends Service { if (typeof this.longitude === 'number' && !isNaN(this.longitude) && this.longitude >= -180 && this.longitude <= 180) { return this.longitude; } - return this.constructor.DEFAULT_LONGITUDE; + return LocationService.DEFAULT_LONGITUDE; + } + + /** + * Retrieves the zoom suggested by the current location source, or null. + * @returns {number|null} + */ + getZoom() { + return isZoom(this.zoom) ? this.zoom : null; } /** - * Attempts to fetch the user's location from various sources including cached data, - * navigator geolocation, or WHOIS data. It first tries to get the cached coordinates. - * If not available or outdated, it tries the browser's geolocation API. - * As a fallback, it uses WHOIS data associated with the user's account. + * Resolves where maps should open. A company-configured default centre + * (Settings > Map) wins outright. Otherwise it tries the active order + * destinations, then the browser's geolocation API, then the IP lookup, + * then the centre of the company's country, then the hardcoded default. + * See utils/map-default-view for the full precedence. * * @returns {Promise} A promise that resolves to an object containing latitude and longitude. */ @@ -102,6 +124,14 @@ export default class LocationService extends Service { return { latitude: this.latitude, longitude: this.longitude }; } + // The company's chosen starting point beats any guess about the user. + await this.mapSettings.load(); + const defaultCenter = this.mapSettings.defaultMapCenter; + if (defaultCenter) { + this.updateLocation(defaultCenter); + return { latitude: defaultCenter.latitude, longitude: defaultCenter.longitude }; + } + try { const coordinates = await this.fetch.cachedGet('fleet-ops/live/coordinates', {}, { expirationInterval: 1, expirationIntervalUnit: 'hour' }); @@ -184,33 +214,51 @@ export default class LocationService extends Service { } } + /** + * Discards the resolved location and resolves it again, e.g. after the + * company's default map view changed. Listeners of `user.located` (the + * live map) move to the new location. + * + * @returns {Promise} The newly resolved coordinates. + */ + async relocate() { + this.located = false; + return this.getUserLocation(); + } + /** * Retrieves the user's location based on WHOIS data associated with their account. - * Defaults to predefined coordinates if WHOIS data is not available. + * When the IP lookup failed it falls back to the centre of the company's + * country, and only then to the predefined coordinates. * * @returns {Object} An object containing latitude and longitude from WHOIS data or default values. */ getUserLocationFromWhois() { - const whois = this.currentUser.getOption('whois', {}); - const coordinates = { - latitude: getWithDefault(whois, 'latitude', this.DEFAULT_LATITUDE), - longitude: getWithDefault(whois, 'longitude', this.DEFAULT_LONGITUDE), - }; - - this.updateLocation(coordinates); - return coordinates; + const whois = this.currentUser.getOption('whois', {}) ?? {}; + const latitude = Number(whois.latitude); + const longitude = Number(whois.longitude); + // The IP lookup's fallback reports null coordinates, which Number() turns into Null Island. + const hasWhoisCoordinates = whois.latitude != null && whois.longitude != null && isLatitude(latitude) && isLongitude(longitude); + + const location = hasWhoisCoordinates + ? { latitude, longitude } + : (this.mapSettings.companyCountryCenter ?? { latitude: LocationService.DEFAULT_LATITUDE, longitude: LocationService.DEFAULT_LONGITUDE }); + + this.updateLocation(location); + return { latitude: location.latitude, longitude: location.longitude }; } /** * Updates the service's tracked properties with the new location data. * Triggers an event to notify other parts of the application that the user's location has been updated. * - * @param {Object} coordinates - An object containing the latitude and longitude to be set. + * @param {Object} coordinates - An object containing the latitude and longitude to be set, and optionally a suggested zoom. */ - updateLocation({ latitude, longitude }) { + updateLocation({ latitude, longitude, zoom = null }) { this.latitude = latitude; this.longitude = longitude; + this.zoom = isZoom(zoom) ? zoom : null; this.located = true; - this.universe.trigger('user.located', { latitude, longitude }); + this.universe.trigger('user.located', { latitude, longitude, zoom: this.zoom }); } } diff --git a/addon/services/map-settings.js b/addon/services/map-settings.js index 9c5022c48..de4c1e03e 100644 --- a/addon/services/map-settings.js +++ b/addon/services/map-settings.js @@ -2,6 +2,7 @@ import Service from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { getLeafletTileUrl } from '../utils/leaflet-tile-url'; +import { defaultMapCenter, companyCountryCenter } from '../utils/map-default-view'; const DEFAULT_SETTINGS = { mapProvider: 'leaflet', @@ -12,6 +13,11 @@ const DEFAULT_SETTINGS = { googleMapsMapType: 'roadmap', showGoogleMapsTrafficLayer: false, showGoogleMapsTransitLayer: false, + defaultMapLatitude: null, + defaultMapLongitude: null, + defaultMapZoom: null, + fitMapToVehicles: false, + companyMapCenter: null, }; export default class MapSettingsService extends Service { @@ -47,6 +53,28 @@ export default class MapSettingsService extends Service { return Boolean(this.settings.showGoogleMapsTransitLayer); } + /** + * The company's configured default centre for the live map, or null. + * + * @return {{latitude: Number, longitude: Number, zoom: Number}|null} + */ + get defaultMapCenter() { + return defaultMapCenter(this.settings); + } + + /** + * The centre of the company's country, used when nothing better is known. + * + * @return {{latitude: Number, longitude: Number, zoom: Number}|null} + */ + get companyCountryCenter() { + return companyCountryCenter(this.settings); + } + + get fitMapToVehicles() { + return Boolean(this.settings.fitMapToVehicles); + } + get isGoogleMaps() { return this.mapProvider === 'google'; } diff --git a/addon/services/order-allocation.js b/addon/services/order-allocation.js index 3b2880b8c..3132c6894 100644 --- a/addon/services/order-allocation.js +++ b/addon/services/order-allocation.js @@ -35,7 +35,7 @@ export default class OrderAllocationService extends Service { @task *loadSettings() { try { const settings = yield this.fetch.get('fleet-ops/settings/orchestrator-settings'); - this.activeEngineId = settings.orchestrator_engine ?? 'greedy'; + this.activeEngineId = settings.allocation_engine ?? 'greedy'; return settings; } catch (error) { this.notifications.serverError(error); diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index a17422d2d..36b53c4c9 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -12429,3 +12429,1394 @@ body[data-theme='dark'] .resource-list-pill .fleetbase-pill-body .resource-pill- font-size: 0.875rem; line-height: 1rem; } + +/* Customer tracking page settings preview: colours come from the --tp-* tokens on the root. */ +.tp-preview { + border: 1px solid var(--tp-border); + border-radius: 1.25rem; + overflow: hidden; + background: var(--tp-bg); + color: var(--tp-text); + font-size: 0.75rem; +} + +.tp-preview-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + background: var(--tp-surface); + border-bottom: 1px solid var(--tp-border); +} + +.tp-preview-monogram { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.5rem; + background: var(--tp-accent); + color: var(--tp-ink); + font-weight: 700; + font-size: 0.6875rem; +} + +.tp-preview-name { + font-weight: 700; + font-size: 0.8125rem; +} + +.tp-preview-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + padding: 0.875rem; +} + +.tp-preview-chip { + padding: 0.125rem 0.5rem; + border-radius: 999px; + background: var(--tp-accent-soft); + color: var(--tp-accent); + font-weight: 600; +} + +.tp-preview-headline { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.tp-preview-sub { + color: var(--tp-muted); +} + +.tp-preview-steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.25rem; + width: 100%; +} + +.tp-preview-step { + height: 0.25rem; + border-radius: 999px; + background: var(--tp-border); +} + +.tp-preview-step-done { + background: var(--tp-accent); +} + +.tp-preview-button { + align-self: stretch; + padding: 0.5rem; + border-radius: 0.625rem; + background: var(--tp-accent); + color: var(--tp-ink); + font-weight: 600; + text-align: center; +} + +.tp-preview-footer { + padding: 0.5rem 0.875rem 0.75rem; + color: var(--tp-muted); + text-align: center; + font-size: 0.6875rem; +} + +/* Customer tracking page. Colours come from the --tp-* tokens the page sets from its company's branding. */ +.tp-root { + display: flex; + flex-direction: column; + min-height: 100vh; + background: var(--tp-bg); + color: var(--tp-text); + font-size: 1rem; + line-height: 1.5; +} + +.tp-root a { + color: var(--tp-accent); + font-weight: 600; +} + +.tp-root h1, +.tp-root h2, +.tp-root h3, +.tp-root p, +.tp-root dl, +.tp-root dd, +.tp-root figure, +.tp-root fieldset { + margin: 0; +} + +.tp-root fieldset { + border: 0; + padding: 0; +} + +.tp-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--tp-surface); + border-bottom: 1px solid var(--tp-border); +} + +.tp-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* The console's locale switcher, in the page's own colours. */ +.tp-root .tp-locale-selector .next-org-button-trigger { + color: var(--tp-text); +} + +.tp-root .tp-locale-selector .next-org-button-trigger:hover, +.tp-root .tp-locale-selector .next-org-button-trigger.is-open { + background: var(--tp-surface2); +} + +.tp-brand { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.tp-brand-logo { + height: 2rem; + max-width: 8rem; + object-fit: contain; +} + +.tp-brand-monogram { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex: none; + border-radius: 0.5rem; + background: var(--tp-accent); + color: var(--tp-ink); + font-size: 0.8125rem; + font-weight: 700; +} + +.tp-brand-name { + overflow: hidden; + font-weight: 700; + white-space: nowrap; + text-overflow: ellipsis; +} + +.tp-main { + flex: 1; + width: 100%; + max-width: 77.5rem; + margin: 0 auto; + box-sizing: border-box; + padding: 1.25rem 1rem; +} + +.tp-narrow { + width: 100%; + max-width: 30rem; + margin: 0 auto; +} + +.tp-wide { + width: 100%; + max-width: 55rem; + margin: 0 auto; +} + +.tp-stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.tp-stack-sm { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tp-stack-xs { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.tp-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.tp-row-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; +} + +.tp-row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.tp-row-end { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.tp-baseline { + align-items: baseline; +} + +.tp-grow { + flex: 1 1 12rem; + min-width: 0; +} + +.tp-self-start { + align-self: flex-start; +} + +.tp-top-border { + padding-top: 0.75rem; + border-top: 1px solid var(--tp-border); +} + +.tp-h1 { + font-size: 1.875rem; + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.tp-h2 { + font-size: 1.375rem; + font-weight: 700; + line-height: 1.2; +} + +.tp-h3 { + font-size: 1.0625rem; + font-weight: 700; +} + +.tp-headline { + font-size: 2rem; + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.03em; + text-wrap: balance; +} + +.tp-subline { + font-size: 1.0625rem; + font-weight: 500; +} + +.tp-eyebrow { + color: var(--tp-muted); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.tp-label { + font-size: 0.875rem; + font-weight: 600; +} + +.tp-small { + font-size: 0.875rem; +} + +.tp-muted { + color: var(--tp-muted); +} + +.tp-strong { + font-weight: 600; +} + +.tp-mono { + font-family: ui-monospace, sfmono-regular, menlo, monospace; + letter-spacing: 0.02em; +} + +.tp-accent-text { + color: var(--tp-accent); +} + +.tp-warn-text { + color: var(--tp-warn); +} + +.tp-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +.tp-touch { + display: inline-flex; + align-items: center; + min-height: 2.75rem; +} + +.tp-card { + padding: 1.125rem; + border: 1px solid var(--tp-border); + border-radius: 1.125rem; + background: var(--tp-surface); +} + +.tp-card-tight { + padding: 0.75rem 0.875rem; + border-radius: 0.875rem; +} + +.tp-card-accent { + border: 2px solid var(--tp-accent); +} + +.tp-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); + gap: 0.75rem; +} + +.tp-input { + width: 100%; + min-height: 3.25rem; + box-sizing: border-box; + padding: 0 0.875rem; + border: 1.5px solid var(--tp-border); + border-radius: 0.75rem; + background: var(--tp-surface); + color: var(--tp-text); + font-size: 1rem; +} + +.tp-textarea { + padding: 0.75rem; + resize: vertical; +} + +.tp-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 3rem; + box-sizing: border-box; + padding: 0 1rem; + border: 1.5px solid var(--tp-border); + border-radius: 0.75rem; + background: var(--tp-surface); + color: var(--tp-text); + font-size: 1rem; + font-weight: 600; + text-align: center; + text-decoration: none; + cursor: pointer; +} + +.tp-root a.tp-button { + color: var(--tp-text); +} + +.tp-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.tp-button-primary, +.tp-root a.tp-button-primary { + border-color: var(--tp-accent); + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-button-small { + min-height: 2.75rem; + padding: 0 0.75rem; + font-size: 0.875rem; +} + +.tp-link-button { + min-height: 2.75rem; + padding: 0 0.25rem; + border: 0; + background: transparent; + color: var(--tp-accent); + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; +} + +.tp-back { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 0.375rem; + min-height: 2.75rem; + border: 0; + background: transparent; + color: var(--tp-text); + font-weight: 600; + cursor: pointer; +} + +.tp-chip { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 0.375rem; + min-height: 2rem; + padding: 0 0.75rem; + border: 0; + border-radius: 999px; + background: var(--tp-surface2); + color: var(--tp-text); + font-size: 0.8125rem; + font-weight: 600; +} + +.tp-chip-accent, +.tp-chip-button { + background: var(--tp-accent-soft); + color: var(--tp-accent); +} + +.tp-chip-button { + align-self: center; + cursor: pointer; +} + +.tp-chip-warn { + background: var(--tp-warn-soft); + color: var(--tp-warn); +} + +.tp-notice { + display: flex; + gap: 0.625rem; + padding: 0.75rem 0.875rem; + border-radius: 0.75rem; + background: var(--tp-surface2); + font-size: 0.9375rem; +} + +.tp-notice-warn { + background: var(--tp-warn-soft); +} + +.tp-icon-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + flex: none; + border-radius: 0.875rem; + background: var(--tp-accent-soft); + color: var(--tp-accent); +} + +.tp-icon-badge-solid { + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-icon-badge-warn { + background: var(--tp-warn-soft); + color: var(--tp-warn); +} + +.tp-icon-badge-soft { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.75rem; +} + +.tp-divider { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--tp-muted); + font-size: 0.8125rem; +} + +.tp-divider::before, +.tp-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--tp-border); +} + +.tp-skeleton { + border-radius: 0.5rem; + background: var(--tp-surface2); +} + +.tp-skeleton-title { + width: 55%; + height: 1.75rem; +} + +.tp-skeleton-line { + width: 80%; + height: 0.75rem; + margin-top: 0.75rem; +} + +.tp-skeleton-card { + height: 9rem; + margin-top: 1.25rem; + border-radius: 1.125rem; +} + +.tp-skeleton-row { + height: 4.5rem; + border-radius: 0.875rem; +} + +.tp-coarse { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.375rem; +} + +.tp-coarse-bar { + height: 0.375rem; + border-radius: 999px; + background: var(--tp-border); +} + +.tp-coarse-done { + background: var(--tp-accent); +} + +.tp-locked-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr)); + gap: 0.75rem; +} + +.tp-locked { + position: relative; + min-height: 9rem; + overflow: hidden; + border: 1px solid var(--tp-border); + border-radius: 1.125rem; + background: var(--tp-surface); +} + +.tp-locked-blur { + position: absolute; + inset: 0; + padding: 1.25rem; + filter: blur(6px); +} + +.tp-locked-overlay { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 9rem; + padding: 1rem; + text-align: center; +} + +.tp-option { + display: flex; + align-items: center; + gap: 0.875rem; + min-height: 4rem; + box-sizing: border-box; + padding: 0.75rem 1rem; + border: 1.5px solid var(--tp-border); + border-radius: 0.875rem; + background: var(--tp-surface); + cursor: pointer; +} + +.tp-option-selected { + border: 2px solid var(--tp-accent); + background: var(--tp-accent-soft); +} + +.tp-option input { + width: 1.25rem; + height: 1.25rem; + margin: 0; + accent-color: var(--tp-accent); +} + +.tp-otp { + position: relative; + max-width: 23.75rem; +} + +.tp-otp-input { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + font-size: 1rem; + opacity: 0; + cursor: text; +} + +.tp-otp-boxes { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.5rem; +} + +.tp-otp-box { + display: flex; + align-items: center; + justify-content: center; + height: 3.625rem; + box-sizing: border-box; + border: 1.5px solid var(--tp-border); + border-radius: 0.75rem; + background: var(--tp-surface); + font-family: ui-monospace, sfmono-regular, menlo, monospace; + font-size: 1.5rem; + font-weight: 600; +} + +.tp-otp-box-active { + border: 2px solid var(--tp-accent); + box-shadow: 0 0 0 4px var(--tp-accent-soft); +} + +.tp-otp-box-wrong { + border: 2px solid var(--tp-warn); +} + +.tp-otp-box-success { + border-color: var(--tp-accent); + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-otp-box-locked { + background: var(--tp-surface2); + color: var(--tp-muted); +} + +.tp-full { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'chips' 'head' 'map' 'body'; + gap: 1.125rem; +} + +.tp-full-chips { + grid-area: chips; +} + +.tp-full-head { + display: flex; + flex-direction: column; + grid-area: head; + gap: 1.125rem; + min-width: 0; +} + +.tp-full-map { + grid-area: map; + min-width: 0; +} + +.tp-full-body { + display: flex; + flex-direction: column; + grid-area: body; + gap: 1rem; + min-width: 0; +} + +.tp-segmented { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-self: flex-start; + padding: 0.25rem; + border-radius: 0.75rem; + background: var(--tp-surface2); +} + +.tp-segment { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2.5rem; + padding: 0 1rem; + border: 0; + border-radius: 0.5625rem; + background: transparent; + color: var(--tp-muted); + font-weight: 600; + cursor: pointer; +} + +.tp-segment-selected { + background: var(--tp-surface); + color: var(--tp-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.tp-live { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--tp-accent); + font-weight: 600; +} + +.tp-live-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--tp-accent); + animation: tp-live-pulse 2s ease-in-out infinite; +} + +@keyframes tp-live-pulse { + 50% { + opacity: 0.35; + } +} + +.tp-steps { + display: flex; + margin: 0; + padding: 0; + list-style: none; +} + +.tp-step { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: 0.5rem; + min-width: 0; +} + +.tp-step-track { + display: flex; + align-items: center; +} + +.tp-step-dot { + display: flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + flex: none; + box-sizing: border-box; + border: 2px solid var(--tp-border); + border-radius: 999px; + background: var(--tp-surface); + font-size: 0.75rem; +} + +.tp-step-line { + flex: 1; + height: 3px; + margin-inline: 0.25rem; + border-radius: 2px; + background: var(--tp-border); +} + +.tp-step-done .tp-step-dot { + border-color: var(--tp-accent); + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-step-done .tp-step-line { + background: var(--tp-accent); +} + +.tp-step-current .tp-step-dot { + border: 3px solid var(--tp-accent); +} + +.tp-step-failed .tp-step-dot { + border-color: var(--tp-warn); + background: var(--tp-warn); + color: var(--tp-surface); +} + +.tp-step-stop .tp-step-dot { + border-color: var(--tp-neutral); + background: var(--tp-neutral); + color: var(--tp-surface); +} + +.tp-step-label { + padding-inline-end: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.25; + overflow-wrap: anywhere; + hyphens: auto; +} + +.tp-step-todo .tp-step-label { + color: var(--tp-muted); + font-weight: 500; +} + +.tp-step-failed .tp-step-label { + color: var(--tp-warn); +} + +.tp-map { + position: relative; + height: 17rem; + overflow: hidden; + border: 1px solid var(--tp-border); + border-radius: 1.25rem; +} + +.tp-map-canvas { + width: 100%; + height: 100%; +} + +/* Full screen: the browser's where it has one, otherwise the map covers the window. */ +.tp-map:fullscreen, +.tp-map-expanded { + height: 100%; + border: 0; + border-radius: 0; +} + +.tp-map-expanded { + position: fixed; + inset: 0; + z-index: 2000; + height: 100dvh; +} + +.tp-map-chip { + position: absolute; + inset-block-start: 0.75rem; + inset-inline-start: 0.75rem; + z-index: 500; + display: flex; + align-items: center; + gap: 0.375rem; + min-height: 2rem; + padding: 0 0.75rem; + border-radius: 999px; + background: var(--tp-surface); + color: var(--tp-accent); + font-size: 0.8125rem; + font-weight: 600; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16); +} + +.tp-map-chip-stale { + color: var(--tp-muted); +} + +.tp-map-controls { + position: absolute; + inset-block-start: 0.75rem; + inset-inline-end: 0.75rem; + z-index: 500; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tp-map-button { + display: flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 0; + font-size: 0.875rem; + line-height: 1; + border-radius: 0.75rem; + background: var(--tp-surface); + color: var(--tp-text); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16); + cursor: pointer; +} + +/* Leaflet positions markers with an inline transform, so a marker's shape is drawn on + its child element; a transform on the marker itself would be overwritten. */ +.tp-map-marker { + background: none; + border: 0; +} + +.tp-root .tp-attribution, +.tp-root .tp-attribution .fleetbase-attribution-link, +.tp-root .tp-attribution .fleetbase-attribution-legal-link { + color: var(--tp-muted); +} + +.tp-map-vehicle { + transition: transform 1s linear; +} + +.tp-map-vehicle-dot { + display: block; + width: 100%; + height: 100%; + border: 3px solid var(--tp-surface); + border-radius: 999px; + background: var(--tp-accent); + box-shadow: 0 0 0 6px var(--tp-accent-soft); +} + +.tp-map-vehicle-stale .tp-map-vehicle-dot { + background: var(--tp-neutral); + opacity: 0.5; +} + +/* Round markers with a label, as the console's order maps draw them: P, a waypoint's + number, D, or a store. */ +.tp-map-point-dot { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + overflow: hidden; + box-sizing: border-box; + border: 2.5px solid #fff; + border-radius: 999px; + box-shadow: 0 2px 8px rgb(0 0 0 / 45%); + color: #fff; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.8125rem; + font-weight: 700; + line-height: 1; +} + +.tp-map-point-dot svg { + width: 0.8125rem; + height: 0.8125rem; + fill: currentcolor; +} + +.tp-map-point-logo .tp-map-point-dot { + background: #fff; +} + +.tp-map-point-dot img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tp-map-point-selected .tp-map-point-dot { + box-shadow: + 0 0 0 4px var(--tp-accent-soft), + 0 2px 8px rgb(0 0 0 / 45%); + transform: scale(1.15); +} + +.tp-map-point-dimmed { + opacity: 0.55; +} + +/* SVG presentation attributes don't resolve CSS variables, so the line takes its + colour here rather than from Leaflet's `color` option. */ +.tp-map-route { + stroke: var(--tp-accent); +} + +.tp-map-route-planned { + stroke-opacity: 0.75; +} + +.tp-root .tp-map-label { + max-width: 12rem; + padding: 0.125rem 0.5rem; + overflow: hidden; + border: 1px solid var(--tp-border); + border-radius: 999px; + background: var(--tp-surface); + box-shadow: 0 1px 3px rgb(0 0 0 / 15%); + color: var(--tp-text); + font-size: 0.75rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tp-root .tp-map-label::before { + display: none; +} + +/* Moved below its marker by the map when it would overlap another label. */ +.tp-root .tp-map-label-below { + box-shadow: 0 -1px 3px rgb(0 0 0 / 15%); +} + +.tp-root .tp-map-label-vehicle { + border-color: var(--tp-accent); + color: var(--tp-accent); +} + +.tp-eta-time { + font-size: 2.25rem; + font-weight: 700; + line-height: 1; + letter-spacing: -0.025em; +} + +.tp-dl { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.9375rem; +} + +.tp-dl div { + display: flex; + justify-content: space-between; + gap: 0.75rem; +} + +.tp-dl dt { + color: var(--tp-muted); +} + +.tp-dl dd { + font-weight: 600; + text-align: end; +} + +.tp-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + flex: none; + border-radius: 999px; + background: var(--tp-accent-soft); + color: var(--tp-accent); + font-size: 1.125rem; + font-weight: 700; +} + +.tp-proof-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; +} + +.tp-proof { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 9.5rem; + overflow: hidden; + padding: 0; + border: 1px solid var(--tp-border); + border-radius: 0.875rem; + background: var(--tp-surface2); + color: var(--tp-muted); + cursor: zoom-in; +} + +.tp-proof img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tp-proof-empty { + cursor: default; +} + +.tp-timeline { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.tp-timeline-day { + padding: 0.375rem 0 0.25rem; +} + +.tp-timeline-entry { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding-bottom: 0.875rem; +} + +.tp-timeline-dot { + width: 0.75rem; + height: 0.75rem; + flex: none; + box-sizing: border-box; + border: 2px solid var(--tp-border); + border-radius: 999px; +} + +/* Every update has happened: earlier ones are filled, the latest takes the accent. */ +.tp-timeline-entry .tp-timeline-dot { + border-color: var(--tp-neutral); + background: var(--tp-neutral); +} + +.tp-timeline-latest .tp-timeline-dot { + border-color: var(--tp-accent); + background: var(--tp-accent); +} + +.tp-item { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 0; + border-top: 1px solid var(--tp-border); +} + +.tp-item:first-of-type { + border-top: 0; +} + +.tp-item-photo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + flex: none; + overflow: hidden; + border-radius: 0.75rem; + background: var(--tp-surface2); + color: var(--tp-muted); +} + +.tp-item-photo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tp-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr)); + gap: 0.5rem; +} + +.tp-list { + display: flex; + flex-direction: column; + gap: 0.625rem; + margin: 0; + padding: 0; + list-style: none; +} + +.tp-list-row { + display: flex; + align-items: center; + gap: 0.875rem; + width: 100%; + min-height: 4.75rem; + box-sizing: border-box; + padding: 0.875rem 1rem; + border: 1px solid var(--tp-border); + border-radius: 0.875rem; + background: var(--tp-surface); + color: var(--tp-text); + text-align: start; + cursor: pointer; +} + +.tp-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2.5rem 1.25rem; + border: 1.5px dashed var(--tp-border); + border-radius: 1rem; + text-align: center; +} + +.tp-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.25rem 2rem; + color: var(--tp-muted); + font-size: 0.8125rem; + text-align: center; +} + +.tp-toast-region { + position: fixed; + inset-inline: 1rem; + bottom: 1.25rem; + z-index: 1000; + display: flex; + justify-content: center; + pointer-events: none; +} + +.tp-toast { + display: flex; + align-items: flex-start; + gap: 0.75rem; + width: 100%; + max-width: 27.5rem; + padding: 0.875rem 1rem; + border-radius: 0.875rem; + background: var(--tp-text); + color: var(--tp-bg); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); + pointer-events: auto; +} + +.tp-toast-title { + font-weight: 600; +} + +.tp-toast-text { + font-size: 0.875rem; + opacity: 0.85; +} + +.tp-toast-close { + margin-inline-start: auto; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.tp-lightbox { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(8, 12, 13, 0.8); +} + +.tp-lightbox-close { + position: absolute; + inset-block-start: 1rem; + inset-inline-end: 1rem; + width: 2.75rem; + height: 2.75rem; + border: 0; + border-radius: 999px; + background: var(--tp-surface); + color: var(--tp-text); + cursor: pointer; +} + +.tp-lightbox-figure { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 40rem; + color: var(--tp-ink); + text-align: center; +} + +.tp-lightbox-figure img { + max-width: 100%; + max-height: 75vh; + border-radius: 1rem; + background: var(--tp-surface); +} + +@media (width >= 960px) { + .tp-main { + padding: 2rem; + } + + .tp-headline { + font-size: 2.75rem; + } + + .tp-full-with-map { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + grid-template-areas: 'chips chips' 'head map' 'body map'; + gap: 1.25rem 2rem; + align-items: start; + } + + .tp-full-with-map .tp-full-map { + position: sticky; + top: 1.5rem; + } + + .tp-full-with-map .tp-map { + height: 40rem; + } + + .tp-full:not(.tp-full-with-map) { + max-width: 47.5rem; + margin: 0 auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .tp-live-dot, + .tp-root .tp-map-vehicle, + .tp-root .tp-map-vehicle-dot { + animation: none; + transition: none; + } +} diff --git a/addon/templates/settings/map.hbs b/addon/templates/settings/map.hbs index fe65273b5..502194b88 100644 --- a/addon/templates/settings/map.hbs +++ b/addon/templates/settings/map.hbs @@ -81,6 +81,52 @@ {{/if}} + {{! ─── Default Map View Panel ──────────────────────────────────────── }} + + {{#if this.settingsLoaded}} +

{{t "settings.map.default-view-help-text"}}

+ + + {{#each (array this.defaultCenterInputKey) as |inputKey|}} + + {{/each}} + {{#if this.hasDefaultMapCenter}} +