diff --git a/README.md b/README.md index 53b2ca9..4b26c88 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,248 @@

-

- -

-

- Open-source Headless Commerce & Marketplace Extension for Fleetbase -

+ Fleetbase Storefront +

+ +

+ Logistics-first headless commerce and marketplace infrastructure for Fleetbase. +

+ +

+ Documentation + | + GitHub + | + Platform Overview + | + Fleetbase +

+ +

+ License: AGPL-3.0-or-later + PHP ^8.0 + Node >=18 + Ember Engine

--- +

+ Fleetbase Storefront dashboard +

+ ## Overview -This monorepo contains both the frontend and backend components of the Storefront extension for Fleetbase. The frontend is built using Ember.js and the backend is implemented in PHP. +Storefront is the commerce extension for Fleetbase. It combines a Laravel API package with an Ember engine for the Fleetbase Console, giving operators the tools to manage stores, products, carts, checkout, customers, marketplace networks, and fulfillment workflows from one logistics-native system. -### Requirements +Unlike a generic e-commerce plugin, Storefront is built around the handoff from purchase to delivery. A checkout can create Fleet-Ops orders, attach storefront order metadata, expose customer and commerce details in the Fleetbase Console, and keep operators close to the real delivery lifecycle. -* PHP 7.3.0 or above -* Ember.js v4.8 or above -* Ember CLI v4.8 or above -* Node.js v18 or above +Read the official guide at [fleetbase.io/docs/storefront](https://www.fleetbase.io/docs/storefront). -## Structure +## Features -``` -├── addon -├── app -├── assets -├── translations -├── config -├── node_modules -├── server -│ ├── config -│ ├── data -│ ├── migrations -│ ├── resources -│ ├── src -│ ├── tests -│ └── vendor -├── tests -├── testem.js -├── index.js -├── package.json -├── phpstan.neon.dist -├── phpunit.xml.dist -├── pnpm-lock.yaml -├── ember-cli-build.js -├── composer.json -├── CONTRIBUTING.md -├── LICENSE.md -├── README.md -``` +- **Store and marketplace management**: run a single store or organize many stores into Storefront networks. +- **Product catalog**: manage products, categories, variants, variant options, addons, addon categories, images, pricing, tags, and availability hours. +- **Locations and mobile commerce**: configure store locations, service areas, food trucks, Fleet-Ops vehicle links, and catalogs assigned to mobile stores. +- **Carts and checkout**: support persistent carts, line items, variants, addons, scheduled items, service quotes, checkout initialization, and order capture. +- **Payments**: configure Stripe, QPay, and cash-on-delivery flows through Storefront gateways. +- **Customers**: support customer registration, login, SMS verification, social login hooks, saved customer metadata, device registration, order history, and account closure flows. +- **Orders and fulfillment**: manage Storefront orders in the console, advance order activity, assign/unassign drivers, mark preparation states, complete pickups, and inspect commerce details alongside route and tracking data. +- **Reviews and votes**: expose API resources for customer reviews and voting. +- **Promotions and notifications**: manage notification channels and send broadcast or transactional push notifications through APNs, FCM, and related Storefront notification classes. +- **Dashboard analytics**: ship Storefront widgets for revenue, order volume, average order value, active orders, completed orders, customers, cart conversion, cancellation rate, revenue trends, top products, customer insights, order status mix, recent orders, and recent customers. +- **Fleet-Ops integration**: register Storefront summaries inside Fleet-Ops order details and add product-as-entity tooling to Fleet-Ops order creation. +- **Internationalization**: include translations for multiple locales through the extension translation files. + +## Architecture + +This repository contains both sides of the Storefront extension: + +| Area | Path | Purpose | +| --- | --- | --- | +| Ember engine | `addon/`, `app/`, `config/`, `translations/` | Fleetbase Console UI, routes, models, components, services, widgets, and translations. | +| Laravel API | `server/src/`, `server/config/`, `server/migrations/` | Storefront API controllers, models, resources, middleware, providers, observers, notifications, jobs, and database migrations. | +| Tests | `tests/`, `server/tests/` | Ember integration tests and backend test scaffolding. | + +The Ember package is published as `@fleetbase/storefront-engine`. The Laravel package is published as `fleetbase/storefront-api`. -## Installation +## Console Modules -### Backend +Storefront registers a Console entry under the `storefront` route and exposes these primary work areas: -Install the PHP packages using Composer: +- **Dashboard**: Storefront-specific operational and analytics widgets. +- **Products**: product catalog, categories, variants, addons, import processing, and product entity creation. +- **Orders**: incoming Storefront orders, activity actions, customer details, commerce summary, documents, route/tracking context, and comments. +- **Customers**: customer records and order history. +- **Networks**: multi-store marketplace networks, store assignment, categories, invitations, and network-level views for stores, customers, and orders. +- **Catalogs**: product bundles and catalog categories, including food truck catalog assignment. +- **Food Trucks**: Fleet-Ops vehicle-linked mobile stores with service area and zone support. +- **Promotions**: push notification campaigns and notification channel workflows. +- **Settings**: store profile, API keys, locations, gateways, and notifications. + +## API Surface + +Storefront exposes two API families. + +### Public Storefront API + +The public customer-facing API is mounted under `storefront/v1` and protected by Storefront API middleware. It includes: + +- Store lookup, store/network information, locations, gateways, search, tags, and network stores. +- Categories, products, food trucks, reviews, and customer-facing catalog browsing. +- Persistent carts with add, update, remove, empty, and delete operations. +- Service quotes from carts. +- Checkout initialization, status, capture, Stripe setup intents, Stripe payment intent updates, and QPay capture callbacks. +- Customer registration, login, SMS code verification, social login endpoints, device registration, saved places, customer orders, phone verification, Stripe customer helpers, and account closure flows. +- Order pickup completion and receipt generation. + +### Internal Console API + +The protected internal API is mounted under `storefront/int/v1`. It powers the Fleetbase Console and includes: + +- CRUD resources for orders, customers, stores, store hours, store locations, products, product hours, variants, variant options, addons, addon categories, gateways, notification channels, reviews, votes, food trucks, catalogs, catalog categories, and catalog hours. +- Order actions for accept, preparing, ready, completed, cancel, and unassign driver. +- Network actions for adding stores, removing stores, categories, invitations, and network lookup. +- Analytics endpoints for overview, revenue trends, order status mix, top products, and customer insights. +- Metrics and operational action endpoints. + +## Getting Started + +Storefront is designed to run inside a Fleetbase installation with `fleetbase/core-api` and `fleetbase/fleetops-api` available. + +### Requirements + +- PHP `^8.0` +- Node.js `>=18` +- pnpm +- Composer +- Fleetbase Core API +- Fleetbase FleetOps API + +### Install Dependencies + +```bash +pnpm install +composer install +``` + +### Backend Package ```bash composer require fleetbase/core-api composer require fleetbase/fleetops-api composer require fleetbase/storefront-api ``` -### Frontend -Install the Ember.js Engine/Addon: +### Frontend Package ```bash pnpm install @fleetbase/storefront-engine ``` -## Usage +## Development + +For the full local Fleetbase workflow, see the [Fleetbase Development Setup guide](https://www.fleetbase.io/docs/platform/quickstart/development-setup). That guide covers cloning the main repository with submodules, mounting live package source, linking extensions, running the Console dev server, and reloading the API after backend changes. -### Backend +### Use Local Storefront Source in Fleetbase + +When developing Storefront inside the Fleetbase monorepo, run the package linker from the repository root so the local `packages/storefront` source replaces the published Storefront packages used by Console and API: -🧹 Keep a modern codebase with **PHP CS Fixer**: ```bash -composer lint +flb-package-linker enable storefront +flb-package-linker install storefront ``` -⚗️ Run static analysis using **PHPStan**: +The linker updates the local Console and API manifests for development. To inspect the current link state: + ```bash -composer test:types +flb-package-linker status +flb-package-linker doctor ``` -✅ Run unit tests using **PEST** +If the linker is not installed yet, install it once from the Fleetbase repository root: + ```bash -composer test:unit +npm link ``` -🚀 Run the entire test suite: +After linking backend package changes, reload the running API worker so Laravel Octane picks up PHP changes: + ```bash -composer test +docker compose exec application php artisan octane:reload ``` -### Frontend +For frontend changes, run the Fleetbase Console dev server as described in the development setup guide; linked Ember packages are watched and live-reloaded by the dev server. + +### Package Commands + +Run the Ember engine locally: -🧹 Keep a modern codebase with **ESLint**: ```bash -pnpm lint +pnpm start ``` -✅ Run unit tests using **Ember/QUnit** +Build the Ember engine: + ```bash -pnpm test -pnpm test:ember -pnpm test:ember-compatibility +pnpm build ``` -🚀 Start the Ember Addon/Engine +Run frontend lint and tests: + ```bash -pnpm start +pnpm lint +pnpm test +pnpm test:ember ``` -🔨 Build the Ember Addon/Engine +Run backend checks: + ```bash -pnpm build +composer test:lint +composer test:types +composer test:unit +composer test ``` +## Configuration + +Storefront configuration is provided through the Laravel package config files and environment variables: + +- `server/config/storefront.php`: API routing, Storefront app verification settings, database connection, and request throttling. +- `server/config/api.php`: Storefront API configuration. +- `server/config/database.connections.php`: Storefront database connection defaults. + +Important environment variables include: + +| Variable | Purpose | +| --- | --- | +| `STOREFRONT_DB_CONNECTION` | Database connection name for Storefront models. | +| `STOREFRONT_BYPASS_VERIFICATION_CODE` | Development bypass code for Storefront app verification flows. | +| `STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE` | Public Storefront API request limit. | +| `STOREFRONT_THROTTLE_DECAY_MINUTES` | Public Storefront API throttle decay window. | + +For customer app configuration, see the [Storefront App configuration docs](https://www.fleetbase.io/docs/storefront/app/configuration). + +## Documentation + +- [Storefront documentation](https://www.fleetbase.io/docs/storefront) +- [Products and catalog](https://docs.fleetbase.io/guides/storefront/products/) +- [Orders and checkout](https://www.fleetbase.io/docs/storefront/orders/overview) +- [Checkout flow](https://www.fleetbase.io/docs/storefront/orders/checkout) +- [Store locations](https://www.fleetbase.io/docs/storefront/stores/store-locations) +- [Storefront App configuration](https://www.fleetbase.io/docs/storefront/app/configuration) + ## Contributing -See the Contributing Guide for details on how to contribute to this project. + +Storefront follows the same review expectations as the rest of Fleetbase: + +- Keep changes small and reviewable. +- Preserve existing architecture and naming conventions. +- Add or update tests when practical. +- Run the relevant frontend or backend validation commands before opening a pull request. +- For API behavior changes, check whether the API specification and public documentation also need updates. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidance. ## License -This project is licensed under the MIT License. + +Fleetbase Storefront is open-source software licensed under the [AGPL-3.0-or-later](LICENSE.md). diff --git a/addon/components/customer-panel/orders.hbs b/addon/components/customer-panel/orders.hbs index fdbcf98..d91de32 100644 --- a/addon/components/customer-panel/orders.hbs +++ b/addon/components/customer-panel/orders.hbs @@ -31,37 +31,7 @@
- {{#if order.isFresh}} -
-
-
@@ -85,29 +55,7 @@
@@ -146,4 +94,4 @@
{{/each}} - \ No newline at end of file + diff --git a/addon/components/customer-panel/orders.js b/addon/components/customer-panel/orders.js index a75248c..2f31e96 100644 --- a/addon/components/customer-panel/orders.js +++ b/addon/components/customer-panel/orders.js @@ -12,8 +12,7 @@ export default class CustomerPanelOrdersComponent extends Component { @service intl; @service appCache; @service modalsManager; - @service contextPanel; - @service orderActions; + @service storefrontOrderActions; @tracked loaded = false; @tracked orders = []; @tracked customer; @@ -50,35 +49,43 @@ export default class CustomerPanelOrdersComponent extends Component { } @action async viewOrder(order) { - this.contextPanel.focus(order, 'viewing'); + return this.storefrontOrderActions.viewOrder(order, { + onChange: () => { + this.loadOrders.perform(); + }, + }); + } + + @action handleOrderChange() { + this.loadOrders.perform(); } @action async acceptOrder(order) { - await this.orderActions.acceptOrder(order, () => { + await this.storefrontOrderActions.acceptOrder(order, () => { this.loadOrders.perform(); }); } @action markAsReady(order) { - this.orderActions.markAsReady(order, () => { + this.storefrontOrderActions.markAsReady(order, () => { this.loadOrders.perform(); }); } @action markAsCompleted(order) { - this.orderActions.markAsCompleted(order, () => { + this.storefrontOrderActions.markAsCompleted(order, () => { this.loadOrders.perform(); }); } @action assignDriver(order) { - this.orderActions.assignDriver(order, () => { + this.storefrontOrderActions.assignDriver(order, () => { this.loadOrders.perform(); }); } @action cancelOrder(order) { - this.orderActions.cancelOrder(order, () => { + this.storefrontOrderActions.cancelOrder(order, () => { this.loadOrders.perform(); }); } diff --git a/addon/components/modals/assign-driver.hbs b/addon/components/modals/assign-driver.hbs index 54b4857..02f6de6 100644 --- a/addon/components/modals/assign-driver.hbs +++ b/addon/components/modals/assign-driver.hbs @@ -12,18 +12,13 @@ -
-
-

- {{t "storefront.component.modals.assign-driver.message-driver"}} -

-
- +
+ - \ No newline at end of file + diff --git a/addon/components/modals/manage-addons.hbs b/addon/components/modals/manage-addons.hbs index 2c807f5..46523a5 100644 --- a/addon/components/modals/manage-addons.hbs +++ b/addon/components/modals/manage-addons.hbs @@ -1,72 +1,5 @@ -s + - \ No newline at end of file + diff --git a/addon/components/modals/manage-addons.js b/addon/components/modals/manage-addons.js index 779f6ad..b47cbd5 100644 --- a/addon/components/modals/manage-addons.js +++ b/addon/components/modals/manage-addons.js @@ -1,10 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { isArray } from '@ember/array'; import { task } from 'ember-concurrency'; -import { all } from 'rsvp'; /** * Represents a component for managing addon categories and individual addons. @@ -12,35 +9,10 @@ import { all } from 'rsvp'; * This component uses various Ember services like store, internationalization, currentUser, modalsManager, and notifications for its operations. */ export default class ModalsManageAddonsComponent extends Component { - @service store; - @service intl; - @service currentUser; - @service modalsManager; - @service notifications; - @tracked categories = []; - @tracked options = {}; - @tracked activeStore; + @tracked addonManagement; - /** - * Constructs the ModalsManageAddonsComponent instance with the given options. - * - * @param {Object} owner - The owner of the instance. - * @param {Object} options - Configuration options for the component. - */ - constructor(owner, { options }) { - super(...arguments); - this.options = options; - this.activeStore = options.store; - this.getAddonCategories.perform(); - } - - /** - * Creates a new addon associated with the provided category. - * @param {Object} category - The category to which the new addon will be added. - */ - @action createNewAddon(category) { - const productAddon = this.store.createRecord('product-addon', { category_uuid: category.id }); - category.addons.pushObject(productAddon); + @action setAddonManagement(addonManagement) { + this.addonManagement = addonManagement; } /** @@ -48,79 +20,8 @@ export default class ModalsManageAddonsComponent extends Component { * Displays loading modal during the operation and handles errors. */ @task *saveChanges() { - this.modalsManager.startLoading(); - try { - yield all(this.categories.map((_) => _.save())); - } catch (error) { - this.modalsManager.stopLoading(); - return this.notifications.serverError(error); - } - yield this.modalsManager.done(); - this.categories = []; - } - - /** - * Removes an addon from the specified category. - * @param {Object} category - The category from which the addon will be removed. - * @param {number} index - The index of the addon to remove. - */ - @task *removeAddon(category, index) { - const addon = category.addons.objectAt(index); - category.addons.removeAt(index); - yield addon.destroyRecord(); - } - - /** - * Saves the provided addon category. - * @param {Object} category - The addon category to save. - */ - @task *saveAddonCategory(category) { - yield category.save(); - } - - /** - * Deletes an addon category at the specified index. - * @param {number} index - The index of the category to delete. - */ - @task *deleteAddonCategory(index) { - const category = this.categories.objectAt(index); - const result = confirm(this.intl.t('storefront.component.modals.manage-addons.delete-this-addon-category-assosiated-will-lost')); - - if (result) { - this.categories = [...this.categories.filter((_, i) => i !== index)]; - yield category.destroyRecord(); - } - } - - /** - * Creates a new addon category with default settings. - */ - @task *createAddonCategory() { - const category = this.store.createRecord('addon-category', { - name: this.intl.t('storefront.component.modals.manage-addons.untitled-addon-category'), - for: 'storefront_product_addon', - owner_type: 'storefront:store', - owner_uuid: this.activeStore.id, - }); - - try { - yield category.save(); - this.categories.pushObject(category); - } catch (error) { - this.notifications.serverError(error); - } - } - - /** - * Retrieves and sets the addon categories associated with the active store. - */ - @task *getAddonCategories() { - const categories = yield this.store.query('addon-category', { owner_uuid: this.activeStore.id }); - if (isArray(categories)) { - this.categories = categories.map((category) => { - category.addons = isArray(category.addons) ? category.addons.filter((addon) => !addon.isNew) : []; - return category; - }); + if (this.addonManagement) { + return yield this.addonManagement.saveChanges.perform(); } } } diff --git a/addon/components/modals/order-ready-assign-driver.hbs b/addon/components/modals/order-ready-assign-driver.hbs index e825d1a..83e23bc 100644 --- a/addon/components/modals/order-ready-assign-driver.hbs +++ b/addon/components/modals/order-ready-assign-driver.hbs @@ -21,7 +21,7 @@
@@ -44,4 +44,4 @@
- \ No newline at end of file + diff --git a/addon/components/modals/order-ready-assign-driver.js b/addon/components/modals/order-ready-assign-driver.js new file mode 100644 index 0000000..597aada --- /dev/null +++ b/addon/components/modals/order-ready-assign-driver.js @@ -0,0 +1,21 @@ +import Component from '@glimmer/component'; +import { action, set } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class ModalsOrderReadyAssignDriverComponent extends Component { + @service intl; + @service modalsManager; + + get dispatchButtonText() { + return this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-accept-button-text'); + } + + get assignAndDispatchButtonText() { + return this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-not-adhoc-accept-button-text'); + } + + @action toggleAdhoc(isAdhoc) { + set(this.args.options, 'adhoc', isAdhoc); + this.modalsManager.setOption('acceptButtonText', isAdhoc ? this.dispatchButtonText : this.assignAndDispatchButtonText); + } +} diff --git a/addon/components/modals/product-image-preview.hbs b/addon/components/modals/product-image-preview.hbs new file mode 100644 index 0000000..c454a9a --- /dev/null +++ b/addon/components/modals/product-image-preview.hbs @@ -0,0 +1,10 @@ + +
+ {{@options.title}} +
+
diff --git a/addon/components/modals/product-image-preview.js b/addon/components/modals/product-image-preview.js new file mode 100644 index 0000000..f8dea5e --- /dev/null +++ b/addon/components/modals/product-image-preview.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class ModalsProductImagePreviewComponent extends Component {} diff --git a/addon/components/order-panel.hbs b/addon/components/order-panel.hbs deleted file mode 100644 index 70530e5..0000000 --- a/addon/components/order-panel.hbs +++ /dev/null @@ -1,314 +0,0 @@ - - -
- {{#if this.order.isFresh}} -
-
- -
-
-
-
- {{this.order.public_id}} -
-
-

{{this.order.public_id}}

-
-
- {{this.order.createdAt}} -
-
- -
-
-
-
- {{#if this.order.tracking_statuses}} -
- - -
-
-
{{trackingStatus.status}}
-
{{n-a trackingStatus.details}}
-
{{trackingStatus.createdAtShortWithTime}}
-
- - -
{{or trackingStatus.details trackingStatus.status}}
-
{{trackingStatus.createdAtShortWithTime}}
-
-
-
-
-
-
- {{/if}} -
-
-
{{t "storefront.common.store"}}
-
- {{this.store.name}} -
-
{{this.store.name}}
- -
-
-
-
-
-
- {{#unless this.order.meta.is_pickup}} -
-
-
{{t "storefront.component.modals.incoming-order.assigned"}}
-
- {{#if this.order.driver_assigned.id}} -
- {{this.order.driver_assigned.name}} -
-
{{n-a this.order.driver_assigned.displayName}}
-
{{n-a - this.order.driver_assigned.phone - (t "storefront.component.modals.incoming-order.no-phone") - }}
-
-
- {{else}} -
-
{{t "storefront.component.modals.incoming-order.not-assigned"}}
-
- {{/if}} - {{#if @options.assignDriver}} -
-
- {{/if}} -
-
-
- {{/unless}} -
-
-
{{t "storefront.common.customer"}}
-
-
-
- {{this.order.customer.name}} -
-
-
{{this.order.customer.name}}
-
{{this.order.customer.email}}
-
{{this.order.customer.phone}}
-
-
- {{#unless this.order.meta.is_pickup}} -
-
{{t "storefront.component.modals.incoming-order.address"}}
-
-
- -
-
- -
-
-
- {{/unless}} -
-
-
-
-
-
-
- {{#if this.order.meta.is_pickup}} -
{{t "storefront.common.pickup"}}
- {{else}} -
{{t "storefront.common.delivery"}}
- {{/if}} -
- {{#if this.order.meta.is_pickup}} -
- {{t "storefront.component.modals.incoming-order.pickup-order"}} -
-
- -
- -
-
- {{else if this.order.payload.hasWaypoints}} - - {{else}} -
-
-
-
- -
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
- {{/if}} -
-
-
-
-
-
-
{{t "storefront.component.modals.incoming-order.tracking"}}
-
({{this.order.tracking}})
-
-
-
- {{this.order.public_id}} -
-
- {{this.order.public_id}} -
-
-
-
-
-
-
-
{{t "storefront.component.modals.incoming-order.summary"}}
- {{#if this.order.payload.cod_amount}} -
- - {{t "storefront.common.cash"}} -
- {{/if}} -
-
-
- {{#each this.order.payload.entities as |entity|}} -
-
-
- {{entity.meta.quantity}}x -
-
-
-
- {{entity.name}} -
-
-

{{entity.name}}

-
-

{{entity.description}}

-
-
- {{#each entity.meta.variants as |variant|}} -
- {{variant.name}} -
- {{/each}} -
-
- {{#each entity.meta.addons as |addon|}} -
- + {{addon.name}} -
- {{/each}} -
-
-
-
- {{format-currency entity.meta.subtotal entity.currency}} -
-
- {{/each}} -
-
-
- {{t "storefront.component.modals.incoming-order.subtotal"}} - {{format-currency this.order.meta.subtotal this.order.meta.currency}} -
- {{#unless this.order.meta.is_pickup}} -
- {{t "storefront.component.modals.incoming-order.fee"}} - {{format-currency this.order.meta.delivery_fee this.order.meta.currency}} -
- {{/unless}} - {{#if this.order.meta.tip}} -
- {{t "storefront.component.modals.incoming-order.tip"}} - {{get-tip-amount this.order.meta.tip this.order.meta.subtotal this.order.meta.currency}} -
- {{/if}} - {{#if this.order.meta.delivery_tip}} -
- {{t "storefront.component.modals.incoming-order.delivery-tip"}} - {{get-tip-amount this.order.meta.delivery_tip this.order.meta.subtotal this.order.meta.currency}} -
- {{/if}} -
-
-
- {{t "storefront.common.total"}} - {{format-currency this.order.meta.total this.order.meta.currency}} -
-
-
-
-
-
-
- -
-
\ No newline at end of file diff --git a/addon/components/order-panel.js b/addon/components/order-panel.js deleted file mode 100644 index ba6b1d6..0000000 --- a/addon/components/order-panel.js +++ /dev/null @@ -1,84 +0,0 @@ -import Component from '@glimmer/component'; -import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-context-component-arguments'; -import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; -import { debug } from '@ember/debug'; -import { task } from 'ember-concurrency'; - -export default class OrderPanelComponent extends Component { - @service storefront; - @service orderActions; - @tracked context = null; - @tracked store = null; - - constructor() { - super(...arguments); - applyContextComponentArguments(this); - this.loadActiveStore.perform(); - } - - /** - * Sets the overlay context. - * - * @action - * @param {OverlayContextObject} overlayContext - */ - @action setOverlayContext(overlayContext) { - this.context = overlayContext; - contextComponentCallback(this, 'onLoad', ...arguments); - } - - /** - * Handles the cancel action. - * - * @method - * @action - * @returns {Boolean} Indicates whether the cancel action was overridden. - */ - @action onPressCancel() { - return contextComponentCallback(this, 'onPressCancel', this.order); - } - - @action acceptOrder(order) { - this.orderActions.acceptOrder(order); - } - - @action markAsReady(order) { - this.orderActions.markAsReady(order); - } - - @action markAsCompleted(order) { - this.orderActions.markAsCompleted(order); - } - - @action assignDriver(order) { - this.orderActions.assignDriver(order); - } - - @action cancelOrder(order) { - this.orderActions.cancelOrder(order); - } - - @task *loadActiveStore() { - const storefrontId = this.order.meta.storefront_id; - if (!storefrontId) { - return null; - } - - const currentStore = this.storefront.activeStore; - if (storefrontId === currentStore.public_id) { - this.store = currentStore; - return currentStore; - } - - try { - const store = yield this.store.findRecord('store', storefrontId); - this.store = store; - return store; - } catch (err) { - debug(`Unable to load store for ${this.order.public_id}:`, err); - } - } -} diff --git a/addon/components/order-panel/details.hbs b/addon/components/order-panel/details.hbs deleted file mode 100644 index be38903..0000000 --- a/addon/components/order-panel/details.hbs +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - {{#if @order.tracking_statuses}} - - -
-
-
{{trackingStatus.status}}
-
{{n-a trackingStatus.details}}
-
{{trackingStatus.createdAtShortWithTime}}
-
- - -
{{or trackingStatus.details trackingStatus.status}}
-
{{trackingStatus.createdAtShortWithTime}}
-
-
-
-
-
- {{else}} -
-

{{t "storefront.orders.index.view.unable-load-order-activity"}}

-
- {{/if}} -
- - -
- {{#if @order.dispatched}} - - {{/if}} - {{#if @order.adhoc}} - {{t "storefront.orders.index.view.ad-hoc"}} - {{/if}} -
-
-
-
{{t "storefront.orders.index.view.customer"}}
-
-
- {{@order.customer.name}} -
-
-
{{n-a @order.customer.name "No Customer"}}
- {{#if @order.customer}} -
{{@order.customer.phone}}
- {{/if}} -
-
-
-
-
{{t "storefront.orders.index.view.facilitator"}}
- {{#if @order.facilitator.isIntegratedVendor}} -
-
- {{@order.facilitator.name}} -
-
-
{{n-a @order.facilitator.name}}
-
-
- {{else}} -
{{n-a @order.facilitator.name "No Facilitator"}}
- {{/if}} -
- -
-
{{t "storefront.common.internal-id"}}
-
{{n-a @order.internal_id}}
-
-
-
{{t "storefront.orders.index.view.tracking-number"}}
-
{{n-a @order.tracking_number.tracking_number}}
-
-
-
{{t "storefront.common.type"}}
-
- {{n-a (humanize @order.type)}} -
-
-
-
{{t "storefront.orders.index.view.date-scheduled"}}
-
{{n-a @order.scheduledAt}}
-
-
-
{{t "storefront.orders.index.view.date-dispatched"}}
-
{{n-a @order.dispatchedAt}}
-
-
-
{{t "storefront.orders.index.view.date-started"}}
-
{{n-a @order.startedAt}}
-
- {{#if @order.pod_required}} -
-
{{t "storefront.orders.index.view.proof-of-delivery"}}
-
{{n-a (smart-humanize @order.pod_method)}}
-
- {{/if}} -
-
- {{!-- - {{#if @order.order_config}} - {{#each this.customFieldGroups as |group|}} - -
- {{#each group.customFields as |customField|}} -
-
{{customField.label}}
- {{#if (and (eq customField.type "file-upload") customField.value.asFile)}} - - {{else}} -
{{n-a customField.value.value}}
- {{/if}} -
- {{/each}} -
-
- {{/each}} - {{/if}} --}} - - {{#if @order.tracking_number}} - -
-
- {{@order.public_id}} -
-
- {{@order.public_id}} -
-
-
- {{/if}} - - -
-
- {{#if @order.meta.is_pickup}} -
-
-
- {{t "storefront.component.modals.incoming-order.pickup-order"}} -
-
-
- -
-
- -
-
-
-
- {{else if @order.payload.hasWaypoints}} - - {{else}} -
-
-
-
- -
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
- {{/if}} -
-
-
- - - - - - - - -
-
\ No newline at end of file diff --git a/addon/components/order-panel/details.js b/addon/components/order-panel/details.js deleted file mode 100644 index b61e3b7..0000000 --- a/addon/components/order-panel/details.js +++ /dev/null @@ -1,187 +0,0 @@ -import Component from '@glimmer/component'; -import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; -import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback'; -import { tracked } from '@glimmer/tracking'; - -export default class OrderPanelDetailsComponent extends Component { - @service store; - @service storefront; - @service fetch; - @service intl; - @service appCache; - @service modalsManager; - @tracked orders = []; - - constructor() { - super(...arguments); - } - - /** - * Sets the overlay context. - * - * @action - * @param {OverlayContextObject} overlayContext - */ - @action setOverlayContext(overlayContext) { - this.context = overlayContext; - contextComponentCallback(this, 'onLoad', ...arguments); - } - - /** - * Handles the cancel action. - * - * @method - * @action - * @returns {Boolean} Indicates whether the cancel action was overridden. - */ - @action onPressCancel() { - return contextComponentCallback(this, 'onPressCancel', this.customer); - } - - @action async viewOrder(order) { - if (order.isFresh) { - return this.acceptOrder(order); - } - - if (order.isPreparing) { - return this.markAsReady(order); - } - - if (order.isPickupReady) { - return this.markAsCompleted(order); - } - - return this.transitionToRoute('orders.index.view', order); - } - - @action async acceptOrder(order) { - const activeStore = this.storefront.activeStore; - - await order.loadPayload(); - await order.loadCustomer(); - - this.modalsManager.show('modals/incoming-order', { - title: this.intl.t('storefront.component.widget.orders.accept-order'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.accept-order'), - acceptButtonScheme: 'success', - acceptButtonIcon: 'check', - order, - activeStore, - confirm: (modal) => { - modal.startLoading(); - - return this.fetch.post('orders/accept', { order: order.id }, { namespace: 'storefront/int/v1' }).then(() => { - return this.fetchOrders().then((orders) => { - this.orders = orders; - modal.stopLoading(); - }); - }); - }, - }); - } - - @action markAsReady(order) { - // for pickup orders - if (order.meta?.is_pickup === true) { - this.modalsManager.confirm({ - title: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-pickup-title'), - body: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-pickup-body'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-pickup-accept-button-text'), - acceptButtonIcon: 'check', - acceptButtonScheme: 'success', - confirm: (modal) => { - modal.startLoading(); - - return this.fetch.post('orders/ready', { order: order.id }, { namespace: 'storefront/int/v1' }).then(() => { - return this.fetchOrders().then((orders) => { - this.orders = orders; - modal.stopLoading(); - }); - }); - }, - }); - } - - if (!order.adhoc) { - // prompt to assign driver then dispatch - return this.modalsManager.show('modals/order-ready-assign-driver', { - title: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-not-adhoc-title'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-not-adhoc-accept-button-text'), - acceptButtonScheme: 'success', - acceptButtonIcon: 'check', - adhoc: false, - driver: null, - order, - confirm: (modal) => { - modal.startLoading(); - - return this.fetch - .post('orders/ready', { order: order.id, driver: modal.getOption('driver.id'), adhoc: modal.getOption('adhoc') }, { namespace: 'storefront/int/v1' }) - .then(() => { - return this.fetchOrders().then((orders) => { - this.orders = orders; - modal.stopLoading(); - }); - }); - }, - }); - } - - this.modalsManager.confirm({ - title: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-title'), - body: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-body'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.mark-as-ready-modal-accept-button-text'), - acceptButtonIcon: 'check', - acceptButtonScheme: 'success', - confirm: (modal) => { - modal.startLoading(); - - return this.fetch.post('orders/ready', { order: order.id }, { namespace: 'storefront/int/v1' }).then(() => { - return this.fetchOrders().then((orders) => { - this.orders = orders; - modal.stopLoading(); - }); - }); - }, - }); - } - - @action markAsCompleted(order) { - this.modalsManager.confirm({ - title: this.intl.t('storefront.component.widget.orders.mark-as-completed-modal-title'), - body: this.intl.t('storefront.component.widget.orders.mark-as-completed-modal-body'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.mark-as-completed-accept-button-text'), - acceptButtonIcon: 'check', - acceptButtonScheme: 'success', - confirm: (modal) => { - modal.startLoading(); - - return this.fetch.post('orders/completed', { order: order.id }, { namespace: 'storefront/int/v1' }).then(() => { - return this.fetchOrders().then((orders) => { - this.orders = orders; - modal.stopLoading(); - }); - }); - }, - }); - } - - @action async assignDriver(order) { - await order.loadDriver(); - - this.modalsManager.show('modals/assign-driver', { - title: this.intl.t('storefront.component.widget.orders.assign-driver-modal-title'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.assign-driver-modal-accept-button-text'), - acceptButtonScheme: 'success', - acceptButtonIcon: 'check', - driver: order.driver_assigned, - order, - confirm: (modal) => { - modal.startLoading(); - - return order.save(); - }, - }); - } -} diff --git a/addon/components/storefront/order/actions.hbs b/addon/components/storefront/order/actions.hbs new file mode 100644 index 0000000..b620485 --- /dev/null +++ b/addon/components/storefront/order/actions.hbs @@ -0,0 +1,13 @@ +
+ {{#each this.items as |item|}} +
diff --git a/addon/components/storefront/order/actions.js b/addon/components/storefront/order/actions.js new file mode 100644 index 0000000..1baa65b --- /dev/null +++ b/addon/components/storefront/order/actions.js @@ -0,0 +1,18 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class StorefrontOrderActionsComponent extends Component { + @service storefrontOrderActions; + + get items() { + return this.storefrontOrderActions.actionItemsFor(this.args.order, this.args.onChange).filter((item) => !item.separator); + } + + get buttonSize() { + return this.args.size ?? 'xs'; + } + + get showText() { + return this.args.showText === true; + } +} diff --git a/addon/components/storefront/order/activity-list.hbs b/addon/components/storefront/order/activity-list.hbs new file mode 100644 index 0000000..7659160 --- /dev/null +++ b/addon/components/storefront/order/activity-list.hbs @@ -0,0 +1,34 @@ +
+ {{#each (or @activity @resource.tracking_statuses) as |trackingStatus|}} +
+
+
+
+ +
+
+
+
{{trackingStatus.status}}
+
{{trackingStatus.details}}
+
+
+
+
{{format-date-fns trackingStatus.created_at "d MMM yyyy HH:mm"}}
+
+
+ {{else}} +
+
+
+ +
+ No order activity +
+
+ Dispatch or update the order to create activity +
+
+
+
+ {{/each}} +
diff --git a/addon/components/storefront/order/activity-list.js b/addon/components/storefront/order/activity-list.js new file mode 100644 index 0000000..628f17b --- /dev/null +++ b/addon/components/storefront/order/activity-list.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderActivityListComponent extends Component {} diff --git a/addon/components/storefront/order/activity-timeline.hbs b/addon/components/storefront/order/activity-timeline.hbs new file mode 100644 index 0000000..233c2bf --- /dev/null +++ b/addon/components/storefront/order/activity-timeline.hbs @@ -0,0 +1,35 @@ +{{#let (or @activity @resource.tracking_statuses) as |activity|}} + {{#if activity.length}} + + +
+
+
{{trackingStatus.status}}
+
{{n-a trackingStatus.details}}
+
{{format-date-fns trackingStatus.created_at "d MMM HH:mm"}}
+
+ + +
{{or trackingStatus.details trackingStatus.status}}
+
{{format-date-fns trackingStatus.created_at "d MMM yyyy HH:mm"}}
+
+
+
+
+
+ {{else}} +
+
+
+ +
+ No order activity +
+
+ Dispatch or update the order to create activity +
+
+
+
+ {{/if}} +{{/let}} diff --git a/addon/components/storefront/order/activity-timeline.js b/addon/components/storefront/order/activity-timeline.js new file mode 100644 index 0000000..f115d44 --- /dev/null +++ b/addon/components/storefront/order/activity-timeline.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderActivityTimelineComponent extends Component {} diff --git a/addon/components/storefront/order/details.hbs b/addon/components/storefront/order/details.hbs new file mode 100644 index 0000000..74c809d --- /dev/null +++ b/addon/components/storefront/order/details.hbs @@ -0,0 +1,29 @@ +
+ {{#if (has-block)}} + {{yield + (hash + Activity=(component "storefront/order/details/activity" resource=@resource order=@resource refresh=@refresh onChange=@onActivityChanged isLoadingActivity=@isLoadingActivity isLoading=@isLoading) + OrderBreakdown=(component "storefront/order/details/order-breakdown" resource=@resource order=@resource refresh=@refresh actionButtons=@orderBreakdownActionButtons isLoading=@isLoading) + Detail=(component "storefront/order/details/detail" resource=@resource order=@resource refresh=@refresh actionButtons=@detailActionButtons isLoading=@isLoading) + CustomerInsights=(component "storefront/order/details/customer-insights" resource=@resource order=@resource refresh=@refresh actionButtons=@customerInsightsActionButtons isLoading=@isLoading) + Route=(component "storefront/order/details/route" resource=@resource order=@resource refresh=@refresh onChange=@onRouteChanged actionButtons=@routeActionButtons isLoading=@isLoading) + Tracking=(component "storefront/order/details/tracking" resource=@resource order=@resource refresh=@refresh actionButtons=@trackingActionButtons isLoading=@isLoading) + Store=(component "storefront/order/details/store" resource=@resource order=@resource refresh=@refresh actionButtons=@storeActionButtons isLoading=@isLoading) + Documents=(component "storefront/order/details/documents" resource=@resource order=@resource refresh=@refresh onChange=@onDocumentsChanged actionButtons=@documentsActionButtons isLoading=@isLoading) + Comments=(component "storefront/order/details/comments" resource=@resource order=@resource refresh=@refresh onChange=@onCommentsChanged actionButtons=@commentsActionButtons isLoading=@isLoading) + Metadata=(component "storefront/order/details/metadata" resource=@resource order=@resource refresh=@refresh onChange=@onMetadataChanged actionButtons=@metadataActionButtons isLoading=@isLoading) + ) + }} + {{else}} + + + + + + + + + + + {{/if}} +
diff --git a/addon/components/storefront/order/details.js b/addon/components/storefront/order/details.js new file mode 100644 index 0000000..1356b58 --- /dev/null +++ b/addon/components/storefront/order/details.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsComponent extends Component {} diff --git a/addon/components/storefront/order/details/activity.hbs b/addon/components/storefront/order/details/activity.hbs new file mode 100644 index 0000000..7270505 --- /dev/null +++ b/addon/components/storefront/order/details/activity.hbs @@ -0,0 +1,14 @@ + + {{#if (eq this.layout "timeline")}} + + {{else}} + + {{/if}} + diff --git a/addon/components/storefront/order/details/activity.js b/addon/components/storefront/order/details/activity.js new file mode 100644 index 0000000..69e934b --- /dev/null +++ b/addon/components/storefront/order/details/activity.js @@ -0,0 +1,87 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +export default class StorefrontOrderDetailsActivityComponent extends Component { + @service appCache; + @service notifications; + @service storefrontOrderActions; + @service storefrontOrderWorkflow; + activityLayoutCacheKey = 'storefront:order:activity:layout:v2'; + @tracked layout = this.appCache.get(this.activityLayoutCacheKey, 'list'); + + constructor() { + super(...arguments); + this.loadActivity.perform(); + } + + get activity() { + const activity = this.args.resource?.tracking_statuses ?? []; + const trackingNumberUuid = this.args.resource?.tracking_number_uuid; + + if (!trackingNumberUuid || typeof activity.filter !== 'function') { + return activity; + } + + return activity.filter((item) => item.tracking_number_uuid === trackingNumberUuid); + } + + get updateActivityItems() { + const order = this.args.resource; + + if (!order || this.storefrontOrderWorkflow.isDefaultStorefrontConfig(order)) { + return []; + } + + return this.storefrontOrderWorkflow.nextActivitiesFor(order).map((activity) => ({ + text: activity._resolved_status ?? activity.status ?? activity.code, + icon: 'signal', + onClick: () => this.storefrontOrderActions.updateActivity(order, activity, this.args.onChange), + })); + } + + /* eslint-disable ember/no-side-effects */ + get actionButtons() { + return [ + { + items: [ + ...this.updateActivityItems, + { + text: 'Reload activity', + icon: 'refresh', + onClick: () => { + this.loadActivity.perform(); + }, + }, + { + text: this.layout === 'timeline' ? 'View activity as list' : 'View activity as timeline', + icon: this.layout === 'timeline' ? 'list' : 'timeline', + onClick: () => { + this.layout = this.layout === 'timeline' ? 'list' : 'timeline'; + this.appCache.set(this.activityLayoutCacheKey, this.layout); + }, + }, + ], + }, + ]; + } + /* eslint-enable ember/no-side-effects */ + + @task *loadActivity() { + const order = this.args.resource; + + if (!order || typeof order.loadTrackingActivity !== 'function') { + return; + } + + try { + yield order.loadTrackingActivity(); + if (typeof this.args.onChange === 'function') { + this.args.onChange(order); + } + } catch (err) { + this.notifications.serverError(err); + } + } +} diff --git a/addon/components/storefront/order/details/comments.hbs b/addon/components/storefront/order/details/comments.hbs new file mode 100644 index 0000000..0dbb1f4 --- /dev/null +++ b/addon/components/storefront/order/details/comments.hbs @@ -0,0 +1,10 @@ + + + diff --git a/addon/components/storefront/order/details/comments.js b/addon/components/storefront/order/details/comments.js new file mode 100644 index 0000000..4667dec --- /dev/null +++ b/addon/components/storefront/order/details/comments.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsCommentsComponent extends Component {} diff --git a/addon/components/storefront/order/details/commerce-summary.hbs b/addon/components/storefront/order/details/commerce-summary.hbs new file mode 100644 index 0000000..4904e39 --- /dev/null +++ b/addon/components/storefront/order/details/commerce-summary.hbs @@ -0,0 +1,71 @@ + +
+ {{#if @resource.payload.entities}} +
+ {{#each @resource.payload.entities as |entity|}} +
+
{{n-a entity.meta.quantity 1}}x
+ {{entity.name}} +
+
{{n-a entity.name "Unnamed item"}}
+
{{n-a entity.description "No description"}}
+ {{#if entity.meta.scheduled_at}} +
+ Scheduled {{format-date-fns entity.meta.scheduled_at "d MMM yyyy HH:mm"}} +
+ {{/if}} + {{#if entity.meta.variants}} +
+ {{#each entity.meta.variants as |variant|}} + {{or variant.name variant.label variant.option}} + {{/each}} +
+ {{/if}} + {{#if entity.meta.addons}} +
+ {{#each entity.meta.addons as |addon|}} + + {{or addon.name addon.label}} + {{/each}} +
+ {{/if}} +
+
+ {{format-currency entity.meta.subtotal (or entity.currency @resource.meta.currency)}} +
+
+ {{/each}} +
+ {{else}} +
+ + No order items were found. +
+ {{/if}} + +
+
Subtotal{{format-currency @resource.meta.subtotal @resource.meta.currency}}
+ {{#unless @resource.meta.is_pickup}} +
Delivery fee{{format-currency @resource.meta.delivery_fee @resource.meta.currency}}
+ {{/unless}} + {{#if @resource.meta.tip}} +
Tip{{get-tip-amount @resource.meta.tip @resource.meta.subtotal @resource.meta.currency}}
+ {{/if}} + {{#if @resource.meta.delivery_tip}} +
Delivery tip{{get-tip-amount @resource.meta.delivery_tip @resource.meta.subtotal @resource.meta.currency}}
+ {{/if}} +
+ Total + {{format-currency (or @resource.meta.total @resource.transaction_amount 0) @resource.meta.currency}} +
+
+
+
diff --git a/addon/components/storefront/order/details/commerce-summary.js b/addon/components/storefront/order/details/commerce-summary.js new file mode 100644 index 0000000..882ecda --- /dev/null +++ b/addon/components/storefront/order/details/commerce-summary.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component {} diff --git a/addon/components/storefront/order/details/customer-insights.hbs b/addon/components/storefront/order/details/customer-insights.hbs new file mode 100644 index 0000000..4c6da1d --- /dev/null +++ b/addon/components/storefront/order/details/customer-insights.hbs @@ -0,0 +1,24 @@ + +
+
+
Customer Type
+
{{n-a this.customerType}}
+
+
+
Storefront Orders
+
{{n-a this.orderCount}}
+
+
+
Customer ID
+ {{n-a this.customer.public_id}} +
+
+
Contact
+
{{this.contactStatus}}
+
+
+
Created
+
{{n-a (format-date-fns this.customer.created_at "d MMM yyyy HH:mm")}}
+
+
+
diff --git a/addon/components/storefront/order/details/customer-insights.js b/addon/components/storefront/order/details/customer-insights.js new file mode 100644 index 0000000..0cbafb2 --- /dev/null +++ b/addon/components/storefront/order/details/customer-insights.js @@ -0,0 +1,39 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsCustomerInsightsComponent extends Component { + get customer() { + return this.args.resource?.customer; + } + + get orderCount() { + return Number(this.customer?.orders ?? this.customer?.order_count ?? 0); + } + + get hasCompleteContact() { + return Boolean(this.customer?.phone && this.customer?.email); + } + + get customerType() { + if (!this.customer) { + return null; + } + + return this.orderCount > 1 ? 'Returning customer' : 'First-time customer'; + } + + get contactStatus() { + if (this.hasCompleteContact) { + return 'Phone and email available'; + } + + if (this.customer?.phone) { + return 'Phone available'; + } + + if (this.customer?.email) { + return 'Email available'; + } + + return 'No contact details'; + } +} diff --git a/addon/components/storefront/order/details/customer.hbs b/addon/components/storefront/order/details/customer.hbs new file mode 100644 index 0000000..d0eb04a --- /dev/null +++ b/addon/components/storefront/order/details/customer.hbs @@ -0,0 +1,24 @@ + +
+
+ {{@resource.customer.name}} +
+
{{n-a @resource.customer.name "No customer"}}
+
{{n-a @resource.customer.email "No email"}}
+
{{n-a @resource.customer.phone "No phone"}}
+
+
+ + {{#unless @resource.meta.is_pickup}} +
+
Delivery address
+ +
+ {{/unless}} +
+
diff --git a/addon/components/storefront/order/details/customer.js b/addon/components/storefront/order/details/customer.js new file mode 100644 index 0000000..dc46580 --- /dev/null +++ b/addon/components/storefront/order/details/customer.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsCustomerComponent extends Component {} diff --git a/addon/components/storefront/order/details/detail.hbs b/addon/components/storefront/order/details/detail.hbs new file mode 100644 index 0000000..ca92fad --- /dev/null +++ b/addon/components/storefront/order/details/detail.hbs @@ -0,0 +1,73 @@ + +
+
+
Customer
+
+
+ {{@resource.customer.name}} +
+
{{n-a @resource.customer.name "No customer"}}
+
{{n-a (or @resource.customer.phone @resource.customer.email) "No contact details"}}
+
+
+
+
+
+
Driver
+
+
+ {{@resource.driver_assigned.name}} +
+
+ {{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}} +
+
{{n-a @resource.driver_assigned.phone "No phone"}}
+
+
+
+
+
+ +
+
+
Order ID
+ {{n-a @resource.public_id}} +
+
+
Internal ID
+ {{n-a @resource.internal_id}} +
+
+
Tracking Number
+ {{n-a (or @resource.tracking_number.tracking_number @resource.tracking)}} +
+
+
Order Type
+
{{n-a (titleize this.orderType)}}
+
+
+
Payment Method
+
{{n-a (titleize this.paymentMethod)}}
+
+
+
Order Amount
+
{{format-currency this.orderAmount this.orderCurrency}}
+
+
+
Transaction ID
+ {{n-a this.transactionId}} +
+
+
Checkout ID
+ {{n-a this.checkoutId}} +
+
+
Created
+
{{n-a @resource.createdAt}}
+
+
+
Scheduled
+
{{n-a @resource.scheduledAt}}
+
+
+
diff --git a/addon/components/storefront/order/details/detail.js b/addon/components/storefront/order/details/detail.js new file mode 100644 index 0000000..3e40ecc --- /dev/null +++ b/addon/components/storefront/order/details/detail.js @@ -0,0 +1,35 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsDetailComponent extends Component { + get orderType() { + return this.args.resource?.type ?? this.args.resource?.order_config?.name ?? this.args.resource?.order_config?.key; + } + + get isCashPayment() { + return Boolean(this.args.resource?.payload?.cod_amount); + } + + get paymentMethod() { + if (this.isCashPayment) { + return 'Cash'; + } + + return this.args.resource?.transaction?.gateway ?? this.args.resource?.meta?.gateway; + } + + get orderAmount() { + return this.args.resource?.transaction?.amount ?? this.args.resource?.transaction_amount ?? this.args.resource?.meta?.total ?? 0; + } + + get orderCurrency() { + return this.args.resource?.transaction?.currency ?? this.args.resource?.meta?.currency; + } + + get transactionId() { + return this.args.resource?.transaction?.public_id ?? this.args.resource?.transaction?.id ?? this.args.resource?.transaction?.uuid ?? this.args.resource?.meta?.transaction_id; + } + + get checkoutId() { + return this.args.resource?.meta?.checkout_id; + } +} diff --git a/addon/components/storefront/order/details/documents.hbs b/addon/components/storefront/order/details/documents.hbs new file mode 100644 index 0000000..21db927 --- /dev/null +++ b/addon/components/storefront/order/details/documents.hbs @@ -0,0 +1,76 @@ + +
+ {{#if this.queueFile.isRunning}} +
+
+ +
+
+ {{else}} + {{#let (file-queue name="files" onFileAdded=(perform this.queueFile) accept=(join "," this.acceptedFileTypes)) as |queue|}} + + {{#if dropzone.active}} + {{#if dropzone.valid}} + {{t "dropzone.drop-to-upload"}} + {{else}} + {{t "dropzone.invalid"}} + {{/if}} + {{else if queue.files.length}} +
+ + {{t "dropzone.files-ready-for-upload" numOfFiles=(pluralize queue.files.length (t "dropzone.file"))}} +
+
({{queue.progress}}%)
+ {{else}} +

+ + {{t "dropzone.upload-documents-files"}} +

+
+ {{#if dropzone.supported}} +

{{t "dropzone.dropzone-supported-files"}}

+ {{/if}} + + {{t "dropzone.or-select-button-text"}} + +
+ {{/if}} +
+ {{/let}} + {{#if this.uploadQueue}} +
+
+ {{t "dropzone.upload-queue"}} +
+
+ {{#each this.uploadQueue as |file|}} +
+
{{truncate-filename file.name 50}}
+
+ + {{round file.progress}}% +
+
+ {{/each}} +
+
+ {{/if}} +
+
+ {{#each @resource.files as |file|}} + + {{/each}} +
+
+ {{/if}} +
+
diff --git a/addon/components/storefront/order/details/documents.js b/addon/components/storefront/order/details/documents.js new file mode 100644 index 0000000..a5ff16b --- /dev/null +++ b/addon/components/storefront/order/details/documents.js @@ -0,0 +1,66 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { debug } from '@ember/debug'; +import { task } from 'ember-concurrency'; + +export default class OrderDetailsDocumentsComponent extends Component { + @service fetch; + @service notifications; + @tracked uploadQueue = []; + @tracked acceptedFileTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/msword', + 'application/pdf', + 'application/x-pdf', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'video/mp4', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-flv', + 'video/x-ms-wmv', + 'audio/mpeg', + 'video/x-msvideo', + 'application/zip', + 'application/x-tar', + ]; + + @task *queueFile(file) { + if (['queued', 'failed', 'timed_out', 'aborted'].indexOf(file.state) === -1) return; + + try { + this.uploadQueue.pushObject(file); + yield this.fetch.uploadFile.perform( + file, + { + path: 'uploads/fleet-ops/order-files', + subject_uuid: this.args.resource.id, + subject_type: 'fleet-ops:order', + type: 'order_file', + }, + (uploadedFile) => { + this.args.resource.files.pushObject(uploadedFile); + this.uploadQueue.removeObject(file); + }, + () => { + this.uploadQueue.removeObject(file); + if (file.queue && typeof file.queue.remove === 'function') { + file.queue.remove(file); + } + } + ); + } catch (err) { + debug('Order document upload failed: ' + err.message); + this.notifications.serverError(err); + } + } + + @task *removeFile(file) { + yield file.destroyRecord(); + } +} diff --git a/addon/components/storefront/order/details/fulfillment.hbs b/addon/components/storefront/order/details/fulfillment.hbs new file mode 100644 index 0000000..508ca07 --- /dev/null +++ b/addon/components/storefront/order/details/fulfillment.hbs @@ -0,0 +1,57 @@ + +
+
+
+
Driver
+
+ {{@resource.driver_assigned.name}} +
+
{{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}}
+
{{n-a @resource.driver_assigned.phone "No phone"}}
+
+
+
+
+
Schedule
+
+
Scheduled{{n-a @resource.scheduledAt "-"}}
+
Dispatched{{n-a @resource.dispatchedAt "-"}}
+
Started{{n-a @resource.startedAt "-"}}
+
+
+
+ +
+
{{if @resource.meta.is_pickup "Pickup" "Route"}}
+ {{#if @resource.meta.is_pickup}} + + {{else if @resource.payload.hasWaypoints}} + + {{else}} +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ {{/if}} +
+
+
diff --git a/addon/components/storefront/order/details/fulfillment.js b/addon/components/storefront/order/details/fulfillment.js new file mode 100644 index 0000000..56f9690 --- /dev/null +++ b/addon/components/storefront/order/details/fulfillment.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsFulfillmentComponent extends Component {} diff --git a/addon/components/storefront/order/details/metadata.hbs b/addon/components/storefront/order/details/metadata.hbs new file mode 100644 index 0000000..6089528 --- /dev/null +++ b/addon/components/storefront/order/details/metadata.hbs @@ -0,0 +1,3 @@ + + + diff --git a/addon/components/storefront/order/details/metadata.js b/addon/components/storefront/order/details/metadata.js new file mode 100644 index 0000000..acc98eb --- /dev/null +++ b/addon/components/storefront/order/details/metadata.js @@ -0,0 +1,8 @@ +import Component from '@glimmer/component'; +import isEmptyObject from '@fleetbase/ember-core/utils/is-empty-object'; + +export default class StorefrontOrderDetailsMetadataComponent extends Component { + get emptyMetadata() { + return isEmptyObject(this.args.resource.meta); + } +} diff --git a/addon/components/storefront/order/details/order-breakdown.hbs b/addon/components/storefront/order/details/order-breakdown.hbs new file mode 100644 index 0000000..2e99126 --- /dev/null +++ b/addon/components/storefront/order/details/order-breakdown.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/storefront/order/details/order-breakdown.js b/addon/components/storefront/order/details/order-breakdown.js new file mode 100644 index 0000000..092c63c --- /dev/null +++ b/addon/components/storefront/order/details/order-breakdown.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsOrderBreakdownComponent extends Component {} diff --git a/addon/components/storefront/order/details/registered-tab.hbs b/addon/components/storefront/order/details/registered-tab.hbs new file mode 100644 index 0000000..3784f4f --- /dev/null +++ b/addon/components/storefront/order/details/registered-tab.hbs @@ -0,0 +1,3 @@ +{{#if this.tab}} + {{component (lazy-engine-component this.tab.component) resource=@resource order=@resource params=this.tab.componentParams}} +{{/if}} diff --git a/addon/components/storefront/order/details/registered-tab.js b/addon/components/storefront/order/details/registered-tab.js new file mode 100644 index 0000000..9d24257 --- /dev/null +++ b/addon/components/storefront/order/details/registered-tab.js @@ -0,0 +1,10 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class StorefrontOrderDetailsRegisteredTabComponent extends Component { + @service('universe/menu-service') menuService; + + get tab() { + return this.menuService.lookupMenuItem('storefront:component:order:details', this.args.class); + } +} diff --git a/addon/components/storefront/order/details/route.hbs b/addon/components/storefront/order/details/route.hbs new file mode 100644 index 0000000..dcec4b4 --- /dev/null +++ b/addon/components/storefront/order/details/route.hbs @@ -0,0 +1,47 @@ + + {{#if this.hasRouteSummaryLine}} +
+
All Stops - {{this.routeStopsCount}}
+
+ + {{#if this.hasTrackingDistance}} + {{format-meters this.trackerData.route.distance_m}} + {{else}} + - + {{/if}} + + - + + {{#if this.trackingDurationSeconds}} + {{format-duration this.trackingDurationSeconds}} + {{else if this.hasCompletionEta}} + {{format-date-fns this.trackerData.eta.completion_at "d MMM HH:mm"}} + {{else}} + - + {{/if}} + +
+
+ {{/if}} + + {{#if @resource.hasIntermediateWaypoints}} + + {{else}} +
+
+ + +
+
+ + +
+ {{#if @resource.payload.return}} +
+ + +
+ {{/if}} +
+ {{/if}} +
diff --git a/addon/components/storefront/order/details/route.js b/addon/components/storefront/order/details/route.js new file mode 100644 index 0000000..c5ae8cf --- /dev/null +++ b/addon/components/storefront/order/details/route.js @@ -0,0 +1,69 @@ +import Component from '@glimmer/component'; +import { debug } from '@ember/debug'; +import { task } from 'ember-concurrency'; + +export default class StorefrontOrderDetailsRouteComponent extends Component { + constructor() { + super(...arguments); + this.loadTrackerData.perform(); + } + + get trackerData() { + return this.args.resource?.tracker_data; + } + + get showRouteEtaData() { + if (this.trackerData?.lifecycle?.show_live_eta !== undefined) { + return Boolean(this.trackerData.lifecycle.show_live_eta); + } + + const status = String(this.args.resource?.status ?? 'created').toLowerCase(); + const hasStarted = Boolean(this.args.resource?.started ?? this.args.resource?.started_at ?? status === 'started'); + + return hasStarted && !['completed', 'canceled'].includes(status); + } + + get hasTrackingRouteSummary() { + return Boolean(this.trackerData?.route || this.trackerData?.eta); + } + + get hasTrackingDistance() { + return this.trackerData?.route?.distance_m !== null && this.trackerData?.route?.distance_m !== undefined; + } + + get hasCompletionEta() { + return this.showRouteEtaData && Boolean(this.trackerData?.eta?.completion_at); + } + + get routeStopsCount() { + const payload = this.args.resource?.payload; + const waypoints = payload?.waypoints; + const waypointCount = typeof waypoints?.toArray === 'function' ? waypoints.toArray().length : Array.isArray(waypoints) ? waypoints.length : (waypoints?.length ?? 0); + + return [payload?.pickup, payload?.dropoff].filter(Boolean).length + waypointCount; + } + + get trackingDurationSeconds() { + if (!this.showRouteEtaData) { + return null; + } + + return this.trackerData?.route?.duration_in_traffic_s ?? this.trackerData?.route?.duration_s; + } + + get hasRouteSummaryLine() { + return this.hasTrackingRouteSummary || this.routeStopsCount > 0; + } + + @task *loadTrackerData() { + if (!this.args.resource || this.args.resource.tracker_data || typeof this.args.resource.loadTrackerData !== 'function') { + return; + } + + try { + yield this.args.resource.loadTrackerData(); + } catch (err) { + debug('Failed to load order tracker data for route: ' + err.message); + } + } +} diff --git a/addon/components/storefront/order/details/store.hbs b/addon/components/storefront/order/details/store.hbs new file mode 100644 index 0000000..d0ca5ca --- /dev/null +++ b/addon/components/storefront/order/details/store.hbs @@ -0,0 +1,51 @@ + +
+ {{this.storeName}} +
+
{{n-a this.storeName "Store"}}
+
+
+ Phone +
+ {{#if this.storePhone}} + {{this.storePhone}} + {{else}} + No phone + {{/if}} +
+
+
+ Email +
+ {{#if this.storeEmail}} + {{this.storeEmail}} + {{else}} + No email + {{/if}} +
+
+
+ Website +
+ {{#if this.storeWebsite}} + {{this.storeWebsite}} + {{else}} + No website + {{/if}} +
+
+
+ Address +
+ {{#if @resource.payload.pickup}} + {{place-address @resource.payload.pickup showTitle=false}} + {{else}} + No pickup address + {{/if}} +
+
+
+
+
+
+
\ No newline at end of file diff --git a/addon/components/storefront/order/details/store.js b/addon/components/storefront/order/details/store.js new file mode 100644 index 0000000..ff10bcc --- /dev/null +++ b/addon/components/storefront/order/details/store.js @@ -0,0 +1,96 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +const storefrontRecordCache = new Map(); + +export default class StorefrontOrderDetailsStoreComponent extends Component { + @service store; + + @tracked storefrontRecord; + @tracked isLoadingStorefront = false; + + get storefrontId() { + return this.args.resource?.meta?.storefront_id; + } + + get storefrontDisplay() { + return this.storefrontRecord ?? {}; + } + + get fallbackStorefrontName() { + const storefront = this.args.resource?.meta?.storefront; + + if (typeof storefront === 'string') { + return storefront; + } + + return storefront?.name; + } + + get storeName() { + return this.storefrontDisplay.name ?? this.fallbackStorefrontName ?? this.storefrontId ?? 'Store'; + } + + get storeLogoUrl() { + return this.storefrontDisplay.logo_url; + } + + get storePhone() { + return this.storefrontDisplay.phone; + } + + get storeEmail() { + return this.storefrontDisplay.email; + } + + get storeWebsite() { + return this.storefrontDisplay.website; + } + + @action setupComponent() { + this.loadStorefront(); + } + + async loadStorefront() { + const storefrontId = this.storefrontId; + const modelName = this.getModelNameFromPublicId(storefrontId); + + if (!storefrontId || !modelName) { + this.storefrontRecord = null; + return; + } + + const cacheKey = `${modelName}:${storefrontId}`; + + if (storefrontRecordCache.has(cacheKey)) { + this.storefrontRecord = await storefrontRecordCache.get(cacheKey); + return; + } + + this.isLoadingStorefront = true; + + const promise = this.store.findRecord(modelName, storefrontId).catch(() => null); + storefrontRecordCache.set(cacheKey, promise); + + this.storefrontRecord = await promise; + this.isLoadingStorefront = false; + } + + getModelNameFromPublicId(publicId) { + if (typeof publicId !== 'string') { + return null; + } + + if (publicId.startsWith('store_')) { + return 'store'; + } + + if (publicId.startsWith('network_')) { + return 'network'; + } + + return null; + } +} diff --git a/addon/components/storefront/order/details/tracking.hbs b/addon/components/storefront/order/details/tracking.hbs new file mode 100644 index 0000000..9c34cd7 --- /dev/null +++ b/addon/components/storefront/order/details/tracking.hbs @@ -0,0 +1,206 @@ + + {{#if this.hasTrackerData}} +
+
+ + + {{this.confidenceLabel}} + confidence · + {{this.confidencePercent}}% + + + + {{this.driverStatusLabel}} + {{#if this.hasDriverLocationAge}} + · + {{format-duration @resource.tracker_data.driver.location_age_seconds}} + {{/if}} + + {{#if @resource.tracker_data.fallback_provider}} + Fallback: + {{this.fallbackProviderLabel}} + {{/if}} +
+ + + + {{#if this.operatorWarning}} +
+
+
+
{{this.operatorWarningTitle}}
+
{{this.operatorWarning}}
+
+ {{#if this.canPingDriver}} + + {{/if}} +
+ {{/if}} + + {{#if this.lifecycleMessage}} +
+
+
+
{{this.lifecycleMessageTitle}}
+
{{this.lifecycleMessage}}
+
+
+ {{/if}} + + {{#if this.showStartEta}} +
+
+
+ Estimated start + Pre-start +
+
+ {{#if this.hasStartEta}} + {{format-duration this.startEtaSeconds}} + {{else}} + Pending start + {{/if}} +
+
Based on the assigned driver route to the first stop
+
+
+ {{else if this.showLiveEta}} +
+
+
+ Smart adjusted ETA + Live +
+
+ {{#if this.hasSmartAdjustedEta}} + {{format-duration this.smartAdjustedEtaSeconds}} + {{else}} + {{this.smartAdjustedEtaUnavailableLabel}} + {{/if}} +
+
Based on provider route and driver signal
+
+
+
Reported ETA
+
+ {{#if this.hasDisplayedReportedEta}} + {{format-duration this.displayedReportedEtaSeconds}} + {{else}} + {{this.etaUnavailableLabel}} + {{/if}} +
+
{{this.reportedEtaWarning}}
+
+
+ +
+
ETA confidence
+
+ {{#each this.confidenceSegments as |segment|}} + + {{/each}} +
+
{{this.confidencePercent}}%
+
+ +
+
{{this.activeStopMarkerLabel}}
+
+
{{this.activeStopLabel}}
+
{{n-a @resource.tracker_data.active_stop.address}}
+
+ ETA: + + {{#if this.hasActiveEta}} + {{format-duration this.activeEtaSeconds}} + {{else}} + {{this.etaUnavailableLabel}} + {{/if}} + +
+
+
+ +
+
+ Reported total + + {{#if this.hasRemainingDistance}} + {{format-meters @resource.tracker_data.route.distance_m}} + {{else}} + - + {{/if}} + +
+ +
+
+
+ Between stops + + {{#if this.hasCurrentLegDistance}} + {{format-meters this.currentLeg.distance_m}} + {{else}} + - + {{/if}} + +
+ +
+
+
Provider context: + {{this.providerLabel}} + route
+
+ {{/if}} + +
+ + Diagnostics + + {{this.diagnosticsSummaryLabel}} + + + +
+ {{#each this.diagnostics as |item|}} +
+ {{item.label}} + {{item.value}} +
+ {{/each}} + {{#if this.hasWarnings}} +
+ {{#each @resource.tracker_data.warnings as |warning|}} + {{warning}} + {{/each}} +
+ {{/if}} +
+
+ + +
+ {{/if}} + +
Labels
+
+
+
+ {{@resource.public_id}} +
+
+ {{@resource.public_id}} +
+
+
+
diff --git a/addon/components/storefront/order/details/tracking.js b/addon/components/storefront/order/details/tracking.js new file mode 100644 index 0000000..f3ac00a --- /dev/null +++ b/addon/components/storefront/order/details/tracking.js @@ -0,0 +1,609 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { debug } from '@ember/debug'; +import { task } from 'ember-concurrency'; + +export default class OrderDetailsTrackingComponent extends Component { + @service fetch; + @service notifications; + + constructor() { + super(...arguments); + this.loadTrackerData.perform(); + } + + @task *loadTrackerData() { + if (!this.args.resource || typeof this.args.resource.loadTrackerData !== 'function') { + return; + } + + try { + yield this.args.resource.loadTrackerData(); + } catch (err) { + debug('Failed to load order tracker data: ' + err.message); + } + } + + get trackerData() { + return this.args.resource?.tracker_data; + } + + get hasTrackerData() { + return Boolean(this.trackerData); + } + + get lifecycle() { + return this.trackerData?.lifecycle ?? {}; + } + + get lifecycleMode() { + if (this.lifecycle.mode) { + return this.lifecycle.mode; + } + + const status = this.statusCode; + + if (['completed', 'canceled'].includes(status)) { + return status; + } + + if (!this.hasAssignedDriver) { + return 'unassigned'; + } + + if (!this.hasOrderStarted && status === 'dispatched') { + return 'dispatched'; + } + + if (!this.hasOrderStarted) { + return 'pre_start'; + } + + return 'active'; + } + + get statusCode() { + return String(this.lifecycle.status ?? this.args.resource?.status ?? 'created').toLowerCase(); + } + + get hasOrderStarted() { + return Boolean(this.lifecycle.has_started ?? this.args.resource?.started ?? this.args.resource?.started_at ?? this.statusCode === 'started'); + } + + get showLiveEta() { + if (this.lifecycle.show_live_eta !== undefined) { + return Boolean(this.lifecycle.show_live_eta); + } + + return this.hasOrderStarted && !this.isTerminalLifecycle; + } + + get showStartEta() { + if (this.lifecycle.show_start_eta !== undefined) { + return Boolean(this.lifecycle.show_start_eta); + } + + return this.lifecycleMode === 'dispatched' && this.hasStartEta; + } + + get isTerminalLifecycle() { + return Boolean(this.lifecycle.is_terminal ?? ['completed', 'canceled'].includes(this.statusCode)); + } + + get isPreStartLifecycle() { + return ['created', 'pre_start'].includes(this.lifecycleMode); + } + + get isDispatchedLifecycle() { + return this.lifecycleMode === 'dispatched'; + } + + get lifecycleMessage() { + if (this.lifecycleMode === 'unassigned') { + return null; + } + + return this.lifecycle.message ?? this.defaultLifecycleMessage; + } + + get defaultLifecycleMessage() { + if (this.statusCode === 'completed') { + return 'Order has been completed.'; + } + + if (this.statusCode === 'canceled') { + return 'Order has been canceled.'; + } + + if (this.isDispatchedLifecycle) { + return 'Order has been dispatched. Estimated start is based on the assigned driver route to the first stop.'; + } + + if (this.isPreStartLifecycle) { + return 'Live ETA will begin once the order is started.'; + } + + return null; + } + + get lifecycleMessageTitle() { + switch (this.lifecycleMode) { + case 'completed': + return 'Order completed'; + case 'canceled': + return 'Order canceled'; + case 'dispatched': + return 'Order dispatched'; + case 'pre_start': + case 'created': + return 'Tracking pending start'; + default: + return null; + } + } + + get lifecycleMessageIcon() { + switch (this.lifecycleMode) { + case 'completed': + return 'circle-check'; + case 'canceled': + return 'ban'; + case 'dispatched': + return 'route'; + default: + return 'clock'; + } + } + + get startEtaSeconds() { + return this.trackerData?.eta?.start_seconds; + } + + get hasStartEta() { + return this.startEtaSeconds !== null && this.startEtaSeconds !== undefined; + } + + get activeEtaSeconds() { + return this.trackerData?.eta?.active_stop_seconds; + } + + get hasActiveEta() { + return this.showLiveEta && this.activeEtaSeconds !== null && this.activeEtaSeconds !== undefined; + } + + get smartAdjustedEtaSeconds() { + if (!this.showLiveEta) { + return null; + } + + return ( + this.firstPositiveNumber(this.activeEtaSeconds) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_in_traffic_s) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_s) ?? + this.firstPositiveNumber(this.reportedEtaSeconds) ?? + null + ); + } + + get hasSmartAdjustedEta() { + return this.smartAdjustedEtaSeconds !== null && this.smartAdjustedEtaSeconds !== undefined; + } + + get smartAdjustedEtaUnavailableLabel() { + if (this.driverSignal === 'Unassigned') { + return 'Pending driver assignment'; + } + + if (this.driverSignal === 'Missing' || this.driverSignal === 'Stale') { + return 'Pending GPS fix'; + } + + return 'Pending start'; + } + + get hasCompletionEta() { + return this.showLiveEta && Boolean(this.trackerData?.eta?.completion_at); + } + + get hasRemainingDistance() { + return this.trackerData?.route?.distance_m !== null && this.trackerData?.route?.distance_m !== undefined; + } + + get hasAssignedDriver() { + const order = this.args.resource; + + return Boolean(order?.driver_assigned || order?.driver_assigned_uuid); + } + + get driverSignal() { + const trackerData = this.trackerData; + + if (!this.hasAssignedDriver) { + return 'Unassigned'; + } + + if (!trackerData?.driver?.location) { + return 'Missing'; + } + + if (trackerData?.insights?.is_location_stale) { + return 'Stale'; + } + + return trackerData?.driver?.online ? 'Live' : 'Offline'; + } + + get driverStatusLabel() { + switch (this.driverSignal) { + case 'Live': + return 'Driver live'; + case 'Stale': + return 'Driver stale'; + case 'Missing': + return 'Driver missing GPS'; + case 'Unassigned': + return 'No driver assigned'; + default: + return 'Driver offline'; + } + } + + get hasDriverLocationAge() { + return this.trackerData?.driver?.location_age_seconds !== null && this.trackerData?.driver?.location_age_seconds !== undefined; + } + + get driverSignalClass() { + switch (this.driverSignal) { + case 'Live': + return 'text-green-600 dark:text-green-400'; + case 'Stale': + return 'text-yellow-600 dark:text-yellow-400'; + case 'Missing': + return 'text-red-600 dark:text-red-400'; + case 'Unassigned': + return 'text-yellow-600 dark:text-yellow-400'; + default: + return 'text-gray-600 dark:text-gray-300'; + } + } + + get routeQualityItems() { + const trackerData = this.trackerData; + + if (!trackerData) { + return []; + } + + const items = [`${this.humanize(trackerData.provider)} route`]; + + if (trackerData.confidence) { + items.push(`${this.humanize(trackerData.confidence)} confidence`); + } + + if (trackerData.fallback_provider) { + items.push(`Fallback: ${this.humanize(trackerData.fallback_provider)}`); + } + + return items; + } + + get confidenceLabel() { + return this.humanize(this.trackerData?.confidence || 'unknown'); + } + + get confidencePercent() { + const score = this.trackerData?.confidence_score ?? this.trackerData?.confidence_percent ?? this.trackerData?.confidence_percentage; + + if (score !== null && score !== undefined && !Number.isNaN(Number(score))) { + return Math.max(0, Math.min(100, Math.round(Number(score)))); + } + + switch (this.trackerData?.confidence) { + case 'high': + return 92; + case 'medium': + return 68; + case 'low': + return 34; + default: + return 0; + } + } + + get providerLabel() { + return this.humanize(this.trackerData?.provider); + } + + get fallbackProviderLabel() { + return this.humanize(this.trackerData?.fallback_provider); + } + + get confidenceToneClass() { + switch (this.trackerData?.confidence) { + case 'high': + return 'tracking-intelligence-pill--good'; + case 'medium': + return 'tracking-intelligence-pill--warn'; + case 'low': + return 'tracking-intelligence-pill--bad'; + default: + return 'tracking-intelligence-pill--muted'; + } + } + + get confidenceSegments() { + const confidence = this.trackerData?.confidence; + const litCount = confidence === 'high' ? 5 : confidence === 'medium' ? 3 : confidence === 'low' ? 2 : 1; + + return Array.from({ length: 5 }, (_, index) => ({ + lit: index < litCount, + })); + } + + get activeStopIndex() { + const stops = this.trackerData?.stops ?? []; + const activeStop = this.trackerData?.active_stop; + const index = stops.findIndex((stop) => this.matchesStop(stop, activeStop)); + + return index >= 0 ? index + 1 : null; + } + + get totalStops() { + return this.trackerData?.stops?.length ?? 0; + } + + get activeStopLabel() { + if (!this.activeStopIndex || !this.totalStops) { + return 'NOW HEADING TO'; + } + + return `NOW HEADING TO - STOP ${this.activeStopIndex} OF ${this.totalStops}`; + } + + get activeStopMarkerLabel() { + const activeStop = this.trackerData?.active_stop; + + if (activeStop?.type === 'pickup') { + return 'P'; + } + + if (activeStop?.type === 'dropoff') { + return 'D'; + } + + return this.activeStopIndex ?? '•'; + } + + get reportedEtaSeconds() { + const activeStop = this.trackerData?.active_stop; + const eta = this.args.resource?.eta ?? {}; + + return activeStop?.eta_seconds ?? eta?.[activeStop?.id] ?? eta?.[activeStop?.uuid] ?? eta?.[activeStop?.public_id] ?? null; + } + + get displayedReportedEtaSeconds() { + if (!this.showLiveEta) { + return null; + } + + return ( + this.firstPositiveNumber(this.reportedEtaSeconds) ?? + this.firstPositiveNumber(this.activeEtaSeconds) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_in_traffic_s) ?? + this.firstPositiveNumber(this.trackerData?.route?.duration_s) ?? + null + ); + } + + get hasDisplayedReportedEta() { + return this.displayedReportedEtaSeconds !== null && this.displayedReportedEtaSeconds !== undefined; + } + + get isReportedEtaUntrusted() { + const trackerData = this.trackerData; + + if (!trackerData) { + return false; + } + + if (!this.showLiveEta) { + return false; + } + + return !trackerData?.driver?.location || trackerData?.insights?.is_location_stale || trackerData.fallback_provider || (trackerData.confidence && trackerData.confidence !== 'high'); + } + + get reportedEtaWarning() { + if (!this.isReportedEtaUntrusted) { + return 'Reported from existing route data'; + } + + return this.operatorWarning ?? 'Reported ETA may not reflect the latest tracking signal.'; + } + + get etaUnavailableLabel() { + if (this.driverSignal === 'Unassigned') { + return 'Pending driver assignment'; + } + + if (this.driverSignal === 'Missing' || this.driverSignal === 'Stale') { + return 'Pending GPS fix'; + } + + return '-'; + } + + get operatorWarningTitle() { + switch (this.driverSignal) { + case 'Unassigned': + return 'No driver assigned'; + case 'Missing': + return 'Pending GPS fix'; + case 'Stale': + return 'Stale driver location'; + default: + return 'Tracking estimate warning'; + } + } + + get canPingDriver() { + return this.driverSignal === 'Missing' || this.driverSignal === 'Stale'; + } + + get warningsCount() { + return (this.trackerData?.warnings ?? []).length; + } + + get diagnosticsSummaryLabel() { + return this.warningsCount === 1 ? '1 warning' : `${this.warningsCount} warnings`; + } + + get totalProgressPercentage() { + const percentage = Number(this.trackerData?.progress?.percentage); + + if (Number.isFinite(percentage)) { + return Math.max(0, Math.min(100, percentage)); + } + + const stops = this.trackerData?.stops ?? []; + const completedStops = stops.filter((stop) => stop.completed).length; + + if (stops.length) { + return Math.max(0, Math.min(100, Math.round((completedStops / stops.length) * 100))); + } + + return 0; + } + + get totalProgressStyle() { + const percentage = this.hasRemainingDistance && this.totalProgressPercentage === 0 ? 2 : this.totalProgressPercentage; + + return `width: ${percentage}%;`; + } + + get currentLeg() { + return this.trackerData?.route?.legs?.[0] ?? null; + } + + get hasCurrentLegDistance() { + return this.currentLeg?.distance_m !== null && this.currentLeg?.distance_m !== undefined; + } + + get currentLegProgressPercentage() { + const explicit = Number(this.currentLeg?.progress_percentage ?? this.trackerData?.progress?.active_leg_percentage); + + if (Number.isFinite(explicit)) { + return Math.max(0, Math.min(100, explicit)); + } + + if (this.driverSignal === 'Unassigned' || this.driverSignal === 'Missing') { + return 0; + } + + if (this.driverSignal === 'Stale') { + return 18; + } + + return Math.max(8, Math.min(92, this.totalProgressPercentage)); + } + + get currentLegProgressStyle() { + return `width: ${this.currentLegProgressPercentage}%;`; + } + + get hasWarnings() { + return (this.trackerData?.warnings ?? []).length > 0; + } + + get diagnostics() { + const trackerData = this.trackerData; + + if (!trackerData) { + return []; + } + + return [ + { label: 'Provider', value: this.humanize(trackerData.provider) }, + { label: 'Fallback', value: trackerData.fallback_provider ? this.humanize(trackerData.fallback_provider) : 'No' }, + { label: 'Traffic aware', value: trackerData.options?.traffic_enabled ? 'Yes' : 'No' }, + { label: 'Confidence', value: this.confidenceLabel }, + { label: 'Driver signal', value: this.driverSignal }, + { label: 'Warnings', value: String((trackerData.warnings ?? []).length) }, + ]; + } + + get operatorWarning() { + const trackerData = this.trackerData; + + if (!trackerData) { + return null; + } + + if (this.isTerminalLifecycle || this.isPreStartLifecycle || this.isDispatchedLifecycle) { + return null; + } + + if (!this.hasAssignedDriver) { + return 'Assign a driver to start live tracking and improve ETA accuracy.'; + } + + if (!trackerData?.driver?.location) { + return 'Driver location is missing, so ETA accuracy may be limited.'; + } + + if (trackerData?.insights?.is_location_stale) { + return 'Driver location is stale. ETA may not reflect the latest movement.'; + } + + if (trackerData.fallback_provider) { + return `Using ${this.humanize(trackerData.fallback_provider)} fallback because the preferred tracking provider was unavailable.`; + } + + if (trackerData.confidence && trackerData.confidence !== 'high') { + return `${this.humanize(trackerData.confidence)} confidence ETA. Treat the estimate as directional.`; + } + + if ((trackerData.warnings ?? []).some((warning) => String(warning).startsWith('provider_failed'))) { + return 'Tracking provider returned an error. Showing the best available estimate.'; + } + + return null; + } + + humanize(value) { + return String(value ?? '') + .replace(/[_:-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (character) => character.toUpperCase()); + } + + firstPositiveNumber(value) { + const number = Number(value); + + return Number.isFinite(number) && number > 0 ? number : null; + } + + matchesStop(stop, activeStop) { + if (!stop || !activeStop) { + return false; + } + + return stop.uuid === activeStop.uuid || stop.public_id === activeStop.public_id || stop.id === activeStop.id; + } + + @task *pingDriver() { + const order = this.args.resource; + + if (!order) { + return; + } + + try { + yield this.fetch.post(`orders/${order.id}/ping-driver`); + this.notifications.success('Driver app ping sent.'); + } catch (err) { + this.notifications.error(err.message ?? 'Unable to ping driver app.'); + } + } +} diff --git a/addon/components/storefront/order/panel-header.hbs b/addon/components/storefront/order/panel-header.hbs new file mode 100644 index 0000000..aefe9ee --- /dev/null +++ b/addon/components/storefront/order/panel-header.hbs @@ -0,0 +1,41 @@ +
+
+
+ +
+
+
+ {{if @resource.meta.is_pickup "Pickup order" "Delivery order"}} +
+
+ {{n-a @resource.public_id "Order"}} +
+
+ {{n-a @resource.createdAt "No created date"}} + {{format-currency (or @resource.meta.total @resource.transaction_amount 0) @resource.meta.currency}} +
+
+
+
+
+ + {{#if @resource.meta.is_pickup}} + + + {{t "storefront.component.widget.orders.pickup-order"}} + + {{else}} + + + Delivery Order + + {{/if}} +
+ +
+
diff --git a/addon/components/storefront/order/panel-header.js b/addon/components/storefront/order/panel-header.js new file mode 100644 index 0000000..0dace7c --- /dev/null +++ b/addon/components/storefront/order/panel-header.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderPanelHeaderComponent extends Component {} diff --git a/addon/components/storefront/product/addon-management.hbs b/addon/components/storefront/product/addon-management.hbs new file mode 100644 index 0000000..f5db858 --- /dev/null +++ b/addon/components/storefront/product/addon-management.hbs @@ -0,0 +1,81 @@ +
+
+
+

Checkout Add-ons

+

Reusable modifier groups customers can select during checkout.

+
+
+ {{#unless (and this.getAddonCategories.isIdle this.saveAddonCategory.isIdle)}} + + {{/unless}} +
+
+ +
+ {{#each this.categories as |category index|}} +
+
+
+ + +
+
+
+
+ +
+ {{#each category.addons as |addon i|}} +
+ + + + +
+ {{else}} +
No add-ons in this group.
+ {{/each}} +
+
+ {{else}} +
+ +

No add-on groups

+

Create reusable checkout modifiers like toppings, sides, or preparation choices.

+
+ {{/each}} +
+
diff --git a/addon/components/storefront/product/addon-management.js b/addon/components/storefront/product/addon-management.js new file mode 100644 index 0000000..da0b214 --- /dev/null +++ b/addon/components/storefront/product/addon-management.js @@ -0,0 +1,96 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { isArray } from '@ember/array'; +import { task } from 'ember-concurrency'; +import { all } from 'rsvp'; + +export default class StorefrontProductAddonManagementComponent extends Component { + @service store; + @service intl; + @service currentUser; + @service modalsManager; + @service notifications; + + @tracked categories = []; + + constructor() { + super(...arguments); + this.args.onReady?.(this); + this.getAddonCategories.perform(); + } + + get activeStore() { + return this.args.store; + } + + @action createNewAddon(category) { + const productAddon = this.store.createRecord('product-addon', { category_uuid: category.id }); + category.addons.pushObject(productAddon); + } + + @task *saveChanges() { + this.modalsManager.startLoading(); + + try { + yield all(this.categories.map((category) => category.save())); + } catch (error) { + this.modalsManager.stopLoading(); + return this.notifications.serverError(error); + } + + yield this.modalsManager.done(); + this.categories = []; + } + + @task *removeAddon(category, index) { + const addon = category.addons.objectAt(index); + category.addons.removeAt(index); + + if (addon.id) { + yield addon.destroyRecord(); + } + } + + @task *saveAddonCategory(category) { + yield category.save(); + } + + @task *deleteAddonCategory(index) { + const category = this.categories.objectAt(index); + const result = confirm(this.intl.t('storefront.component.modals.manage-addons.delete-this-addon-category-assosiated-will-lost')); + + if (result) { + this.categories = this.categories.filter((_, i) => i !== index); + yield category.destroyRecord(); + } + } + + @task *createAddonCategory() { + const category = this.store.createRecord('addon-category', { + name: this.intl.t('storefront.component.modals.manage-addons.untitled-addon-category'), + for: 'storefront_product_addon', + owner_type: 'storefront:store', + owner_uuid: this.activeStore.id, + }); + + try { + yield category.save(); + this.categories.pushObject(category); + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *getAddonCategories() { + const categories = yield this.store.query('addon-category', { owner_uuid: this.activeStore.id }); + + if (isArray(categories)) { + this.categories = categories.map((category) => { + category.addons = isArray(category.addons) ? category.addons.filter((addon) => !addon.isNew) : []; + return category; + }); + } + } +} diff --git a/addon/components/storefront/product/card.hbs b/addon/components/storefront/product/card.hbs new file mode 100644 index 0000000..1c7a313 --- /dev/null +++ b/addon/components/storefront/product/card.hbs @@ -0,0 +1,117 @@ +{{#if (eq @viewMode "list")}} +
+ + {{@product.name}} + + {{n-a @product.name "Untitled product"}} + {{n-a @product.description "No description"}} + {{n-a @product.sku "No SKU"}} + + + + {{#if this.hasSalePrice}} + {{format-currency @product.sale_price @product.currency}} + {{else}} + {{format-currency @product.price @product.currency}} + {{/if}} + + + {{this.variantCount}} variants · {{this.addonGroupCount}} add-ons + + + + +
+{{else}} +
+
+ + {{@product.name}} + +
+ +
+ +
+ +
+
+
+
{{n-a this.categoryName "Uncategorized"}}
+ + {{n-a @product.name "Untitled product"}} + +
+
+ {{#if this.hasSalePrice}} + {{format-currency @product.sale_price @product.currency}} + {{format-currency @product.price @product.currency}} + {{else}} + {{format-currency @product.price @product.currency}} + {{/if}} +
+
+ +

{{n-a @product.description "No description"}}

+
{{n-a @product.sku "No SKU"}}
+ +
+ {{this.variantCount}} variants + {{this.addonGroupCount}} add-ons +
+ +
+ {{#if @product.is_service}} + Service + {{/if}} + {{#if @product.is_bookable}} + Bookable + {{/if}} + {{#if @product.is_recommended}} + Recommended + {{/if}} + {{#if @product.is_on_sale}} + On sale + {{/if}} +
+
+ + +
+{{/if}} diff --git a/addon/components/storefront/product/card.js b/addon/components/storefront/product/card.js new file mode 100644 index 0000000..475e0a6 --- /dev/null +++ b/addon/components/storefront/product/card.js @@ -0,0 +1,59 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import { inject as service } from '@ember/service'; + +const FALLBACK_IMAGE_URL = 'https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png'; +const PUBLISHED_STATUSES = ['published', 'available', 'active']; + +export default class StorefrontProductCardComponent extends Component { + @service modalsManager; + + get imageUrl() { + return this.args.product?.primary_image_url ?? FALLBACK_IMAGE_URL; + } + + get categoryName() { + const category = this.args.product?.category; + return category ? get(category, 'name') : null; + } + + get variantCount() { + return this.args.product?.variants?.length ?? 0; + } + + get addonGroupCount() { + return this.args.product?.addon_categories?.length ?? 0; + } + + get hasSalePrice() { + const { product } = this.args; + return Boolean(product?.is_on_sale && product?.sale_price); + } + + get displayStatus() { + const { product } = this.args; + const status = product?.status; + + if (status === 'draft') { + return 'draft'; + } + + if (PUBLISHED_STATUSES.includes(status)) { + return 'published'; + } + + return product?.is_available === false ? 'draft' : 'published'; + } + + @action previewImage() { + this.modalsManager.show('modals/product-image-preview', { + title: this.args.product?.name ?? 'Product image', + modalClass: 'storefront-product-image-preview-modal', + modalBodyClass: 'storefront-product-image-preview-modal__body', + hideFooterActions: true, + modalFooterClass: 'hidden-i', + imageUrl: this.imageUrl, + product: this.args.product, + }); + } +} diff --git a/addon/components/storefront/product/category-form.hbs b/addon/components/storefront/product/category-form.hbs new file mode 100644 index 0000000..dd2932d --- /dev/null +++ b/addon/components/storefront/product/category-form.hbs @@ -0,0 +1,33 @@ + + + + + diff --git a/addon/components/storefront/product/category-sidebar.hbs b/addon/components/storefront/product/category-sidebar.hbs new file mode 100644 index 0000000..a1eba01 --- /dev/null +++ b/addon/components/storefront/product/category-sidebar.hbs @@ -0,0 +1,52 @@ + diff --git a/addon/components/storefront/product/category-sidebar.js b/addon/components/storefront/product/category-sidebar.js new file mode 100644 index 0000000..d2a40e9 --- /dev/null +++ b/addon/components/storefront/product/category-sidebar.js @@ -0,0 +1,7 @@ +import Component from '@glimmer/component'; + +export default class StorefrontProductCategorySidebarComponent extends Component { + get categories() { + return this.args.categories ?? []; + } +} diff --git a/addon/components/storefront/product/collection.hbs b/addon/components/storefront/product/collection.hbs new file mode 100644 index 0000000..b7d6948 --- /dev/null +++ b/addon/components/storefront/product/collection.hbs @@ -0,0 +1,44 @@ +
+
+ {{#if this.hasProducts}} + {{#if this.isListView}} +
+
+ Product + Price + Status + Options + +
+ {{#each this.products as |product|}} + + {{/each}} +
+ {{else}} + {{#each this.products as |product|}} + + {{/each}} + {{/if}} + {{else}} +
+ +

No products found

+

{{if @category "Create the first product in this category." "Create a category, then add products to your catalog."}}

+
+ {{/if}} +
+ +
+ +
+
diff --git a/addon/components/storefront/product/collection.js b/addon/components/storefront/product/collection.js new file mode 100644 index 0000000..fbe8594 --- /dev/null +++ b/addon/components/storefront/product/collection.js @@ -0,0 +1,19 @@ +import Component from '@glimmer/component'; + +export default class StorefrontProductCollectionComponent extends Component { + get products() { + return this.args.products ?? []; + } + + get hasProducts() { + return this.products.length > 0; + } + + get isListView() { + return this.args.viewMode === 'list'; + } + + get title() { + return this.args.category ? `${this.args.category.name} Products` : 'All Products'; + } +} diff --git a/addon/components/storefront/product/form.hbs b/addon/components/storefront/product/form.hbs new file mode 100644 index 0000000..35819af --- /dev/null +++ b/addon/components/storefront/product/form.hbs @@ -0,0 +1,50 @@ +
+ {{#let (cannot @permission) as |unauthorized|}} + + + + + + + + + + + {{/let}} +
diff --git a/addon/components/storefront/product/form/addons.hbs b/addon/components/storefront/product/form/addons.hbs new file mode 100644 index 0000000..b0605e5 --- /dev/null +++ b/addon/components/storefront/product/form/addons.hbs @@ -0,0 +1,58 @@ + +
+
+

Checkout modifiers

+

Attach reusable add-on groups and hide options that should not apply to this product.

+
+
+
+ {{#each @product.addon_categories as |addonCategory index|}} +
+
+
+ + {{addonCategory.name}} +
+
+
+ {{#each addonCategory.category.addons as |addon|}} +
+ +
+ {{addon.name}} + {{n-a addon.description "No description"}} +
+
+ {{format-currency addon.price @product.currency}} +
+
+ {{else}} +
No add-ons available in this group.
+ {{/each}} +
+
+ {{else}} +
No add-on groups selected.
+ {{/each}} +
+
diff --git a/addon/components/storefront/product/form/availability.hbs b/addon/components/storefront/product/form/availability.hbs new file mode 100644 index 0000000..5f6c9eb --- /dev/null +++ b/addon/components/storefront/product/form/availability.hbs @@ -0,0 +1,9 @@ + + + diff --git a/addon/components/storefront/product/form/basics.hbs b/addon/components/storefront/product/form/basics.hbs new file mode 100644 index 0000000..62671f4 --- /dev/null +++ b/addon/components/storefront/product/form/basics.hbs @@ -0,0 +1,52 @@ + + + +