From 442f983566844eb24a8505ab5fe92c28770c620c Mon Sep 17 00:00:00 2001 From: fleetbase-agent Date: Wed, 27 May 2026 11:13:25 +0000 Subject: [PATCH 01/14] fix: address issue #71 --- addon/extension.js | 67 ++++++++++++++++++++++++++++-------- addon/templates/home.hbs | 6 ++-- tests/unit/extension-test.js | 33 ++++++++++++++++++ 3 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 tests/unit/extension-test.js diff --git a/addon/extension.js b/addon/extension.js index 3a1e416..e5ea862 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -1,5 +1,56 @@ import { Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; +function createStorefrontKeyMetricsWidget() { + return new Widget({ + id: 'storefront-key-metrics-widget', + name: 'Storefront Metrics', + description: 'Key metrics from Storefront.', + icon: 'store', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/storefront-key-metrics'), + grid_options: { w: 12, h: 7, minW: 8, minH: 7 }, + options: { title: 'Storefront Metrics' }, + default: true, + }); +} + +export function registerWidgets(widgetService) { + widgetService.registerDashboard('storefront'); + + widgetService.registerWidgets('storefront', [ + new Widget({ + id: 'storefront-metrics-widget', + name: 'Storefront Metrics', + description: 'Storefront order, customer, store, and earnings metrics.', + icon: 'chart-line', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/storefront-metrics'), + grid_options: { w: 12, h: 4, minW: 8, minH: 4 }, + default: true, + }), + new Widget({ + id: 'storefront-orders-widget', + name: 'Storefront Orders', + description: 'Recent Storefront orders.', + icon: 'bag-shopping', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders'), + grid_options: { w: 12, h: 10, minW: 8, minH: 8 }, + options: { wrapperClass: 'bordered-classic' }, + default: true, + }), + new Widget({ + id: 'storefront-customers-widget', + name: 'Storefront Customers', + description: 'Recent Storefront customers.', + icon: 'users', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/customers'), + grid_options: { w: 12, h: 10, minW: 8, minH: 8 }, + options: { wrapperClass: 'bordered-classic' }, + default: true, + }), + ]); + + widgetService.registerWidgets('dashboard', [createStorefrontKeyMetricsWidget()]); +} + export default { setupExtension(app, universe) { const menuService = universe.getService('menu'); @@ -51,21 +102,7 @@ export default { ], }); - // widgets for registry - const widgets = [ - new Widget({ - id: 'storefront-key-metrics-widget', - name: 'Storefront Metrics', - description: 'Key metrics from Storefront.', - icon: 'store', - component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/storefront-key-metrics'), - grid_options: { w: 12, h: 7, minW: 8, minH: 7 }, - options: { title: 'Storefront Metrics' }, - default: true, - }), - ]; - - widgetService.registerWidgets('dashboard', widgets); + registerWidgets(widgetService); // register component to views registryService.registerRenderableComponent('fleet-ops:component:order:details', new ExtensionComponent('@fleetbase/storefront-engine', 'storefront-order-summary')); diff --git a/addon/templates/home.hbs b/addon/templates/home.hbs index 9dc225a..c977668 100644 --- a/addon/templates/home.hbs +++ b/addon/templates/home.hbs @@ -1,8 +1,6 @@ - - - + - \ No newline at end of file + diff --git a/tests/unit/extension-test.js b/tests/unit/extension-test.js new file mode 100644 index 0000000..5d52b53 --- /dev/null +++ b/tests/unit/extension-test.js @@ -0,0 +1,33 @@ +import { module, test } from 'qunit'; +import { registerWidgets } from '@fleetbase/storefront-engine/extension'; + +module('Unit | Extension', function () { + test('registers storefront dashboard widgets', function (assert) { + const dashboards = []; + const widgetRegistrations = []; + const widgetService = { + registerDashboard(id) { + dashboards.push(id); + }, + registerWidgets(id, widgets) { + widgetRegistrations.push({ id, widgets }); + }, + }; + + registerWidgets(widgetService); + + const storefrontRegistration = widgetRegistrations.find((registration) => registration.id === 'storefront'); + const dashboardRegistration = widgetRegistrations.find((registration) => registration.id === 'dashboard'); + + assert.deepEqual(dashboards, ['storefront']); + assert.deepEqual( + storefrontRegistration.widgets.map((widget) => widget.id), + ['storefront-metrics-widget', 'storefront-orders-widget', 'storefront-customers-widget'] + ); + assert.true(storefrontRegistration.widgets.every((widget) => widget.default)); + assert.deepEqual( + dashboardRegistration.widgets.map((widget) => widget.id), + ['storefront-key-metrics-widget'] + ); + }); +}); From 38d2c700ce06bdf7514b590f8137d734831091f4 Mon Sep 17 00:00:00 2001 From: fleetbase-agent Date: Wed, 27 May 2026 11:34:33 +0000 Subject: [PATCH 02/14] fix: address issue #75 --- .../src/Http/Controllers/OrderController.php | 8 +++ server/src/Http/Resources/v1/Index/Order.php | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 server/src/Http/Resources/v1/Index/Order.php diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index c4d963c..2222986 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController as FleetbaseOrderController; use Fleetbase\FleetOps\Models\Order; +use Fleetbase\Storefront\Http\Resources\v1\Index\Order as StorefrontOrderIndexResource; use Fleetbase\Storefront\Notifications\StorefrontOrderAccepted; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Http\Request; @@ -18,6 +19,13 @@ class OrderController extends FleetbaseOrderController */ public $resource = 'order'; + /** + * The resource to use for index queries. + * + * @var string + */ + public $indexResource = StorefrontOrderIndexResource::class; + /** * The filter to use. * diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php new file mode 100644 index 0000000..8c8355c --- /dev/null +++ b/server/src/Http/Resources/v1/Index/Order.php @@ -0,0 +1,64 @@ +normalizeMeta(data_get($order, 'meta', [])), + $this->storefrontOrderMeta() + ); + + return $order; + } + + private function storefrontOrderMeta(): array + { + $keys = [ + 'storefront', + 'storefront_id', + 'storefront_network', + 'storefront_network_id', + 'subtotal', + 'delivery_fee', + 'tip', + 'delivery_tip', + 'total', + 'currency', + 'gateway', + 'is_pickup', + 'is_master_order', + 'related_orders', + 'master_order_id', + ]; + + return array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys)); + } + + private function normalizeMeta($meta): array + { + if ($meta instanceof Arrayable) { + $meta = $meta->toArray(); + } + + if (is_object($meta)) { + $meta = (array) $meta; + } + + return is_array($meta) ? $meta : []; + } +} From a0e3458657b9f1a5f3624fb71bc7d869e49c6837 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 15:17:40 +0800 Subject: [PATCH 03/14] Fix storefront customer signup creation --- .../Controllers/v1/CustomerController.php | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 8ef73ad..962e833 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -199,12 +199,14 @@ public function create(CreateCustomerRequest $request) // create the user $user = User::create(array_merge( [ - 'type' => 'customer', 'company_uuid' => session('company'), 'phone' => static::phone($request->input('phone')), ], - $request->only(['name', 'type', 'email', 'phone', 'meta']) + $request->only(['name', 'email', 'phone', 'meta']) )); + $user->setUserType('customer'); + } elseif (!$user->type) { + $user->setUserType('customer'); } // always customer type @@ -239,14 +241,20 @@ public function create(CreateCustomerRequest $request) } // create the customer/contact - try { - $customer = Contact::create($input); - } catch (UserAlreadyExistsException $e) { - // If the exception is thrown because user already exists and - // that user is the same user already assigned continue - $customer = Contact::where(['company_uuid' => session('company'), 'phone' => $input['phone']])->first(); - } catch (\Exception $e) { - return response()->apiError($e->getMessage()); + $customer = Contact::where(['company_uuid' => session('company'), 'user_uuid' => $user->uuid, 'type' => 'customer'])->first(); + if (!$customer) { + try { + $customer = Contact::create($input); + } catch (UserAlreadyExistsException $e) { + // If the exception is thrown because user already exists and + // that user is the same user already assigned continue + $customer = Contact::where(['company_uuid' => session('company'), 'user_uuid' => $user->uuid, 'type' => 'customer'])->first(); + if (!$customer) { + return response()->apiError($e->getMessage()); + } + } catch (\Exception $e) { + return response()->apiError($e->getMessage()); + } } // generate auth token From 9dc69a652ef84d9e704ab722b206eade23cc64c0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 15:21:05 +0800 Subject: [PATCH 04/14] v0.4.15 --- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index c57ebc6..160c7b6 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.14", + "version": "0.4.15", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index c306c20..a186929 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.14", + "version": "0.4.15", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "repository": "https://github.com/fleetbase/storefront", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index c1912fd..18693a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.14", + "version": "0.4.15", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", From ce7414b234705f7413838448210c2cbca13f457f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 15:28:30 +0800 Subject: [PATCH 05/14] Fix storefront order index resource signature --- server/src/Http/Resources/v1/Index/Order.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php index 8c8355c..4fd70fb 100644 --- a/server/src/Http/Resources/v1/Index/Order.php +++ b/server/src/Http/Resources/v1/Index/Order.php @@ -11,10 +11,8 @@ class Order extends FleetOpsOrderIndexResource * Transform the resource into an array. * * @param \Illuminate\Http\Request $request - * - * @return array */ - public function toArray($request) + public function toArray($request): array { $order = parent::toArray($request); From 56fa13fbaf00150b66eb37ac5661a6fc22183bf5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 16:02:02 +0800 Subject: [PATCH 06/14] Overhaul storefront dashboard widgets --- addon/components/widget/customer-insights.hbs | 44 +++ addon/components/widget/customer-insights.js | 42 +++ addon/components/widget/customers.hbs | 16 +- addon/components/widget/kpi-active-orders.hbs | 1 + addon/components/widget/kpi-active-orders.js | 5 + addon/components/widget/kpi-aov.hbs | 1 + addon/components/widget/kpi-aov.js | 5 + .../components/widget/kpi-cart-conversion.hbs | 1 + .../components/widget/kpi-cart-conversion.js | 5 + addon/components/widget/kpi-customers.hbs | 1 + addon/components/widget/kpi-customers.js | 5 + addon/components/widget/kpi-orders.hbs | 1 + addon/components/widget/kpi-orders.js | 5 + addon/components/widget/kpi-revenue.hbs | 1 + addon/components/widget/kpi-revenue.js | 5 + addon/components/widget/kpi-tile.hbs | 27 ++ addon/components/widget/kpi-tile.js | 91 +++++ addon/components/widget/orders-by-status.hbs | 21 ++ addon/components/widget/orders-by-status.js | 57 +++ addon/components/widget/orders.hbs | 28 +- addon/components/widget/orders.js | 2 +- addon/components/widget/revenue-trend.hbs | 25 ++ addon/components/widget/revenue-trend.js | 88 +++++ addon/components/widget/top-products.hbs | 30 ++ addon/components/widget/top-products.js | 39 +++ addon/extension.js | 120 ++++++- addon/styles/storefront-engine.css | 325 +++++++++++++++++ addon/templates/home.hbs | 7 +- app/components/widget/customer-insights.js | 1 + app/components/widget/kpi-active-orders.js | 1 + app/components/widget/kpi-aov.js | 1 + app/components/widget/kpi-cart-conversion.js | 1 + app/components/widget/kpi-customers.js | 1 + app/components/widget/kpi-orders.js | 1 + app/components/widget/kpi-revenue.js | 1 + app/components/widget/kpi-tile.js | 1 + app/components/widget/orders-by-status.js | 1 + app/components/widget/revenue-trend.js | 1 + app/components/widget/top-products.js | 1 + .../Http/Controllers/AnalyticsController.php | 327 ++++++++++++++++++ server/src/routes.php | 10 + .../components/widget/kpi-tile-test.js | 59 ++++ .../components/widget/top-products-test.js | 50 +++ 43 files changed, 1427 insertions(+), 28 deletions(-) create mode 100644 addon/components/widget/customer-insights.hbs create mode 100644 addon/components/widget/customer-insights.js create mode 100644 addon/components/widget/kpi-active-orders.hbs create mode 100644 addon/components/widget/kpi-active-orders.js create mode 100644 addon/components/widget/kpi-aov.hbs create mode 100644 addon/components/widget/kpi-aov.js create mode 100644 addon/components/widget/kpi-cart-conversion.hbs create mode 100644 addon/components/widget/kpi-cart-conversion.js create mode 100644 addon/components/widget/kpi-customers.hbs create mode 100644 addon/components/widget/kpi-customers.js create mode 100644 addon/components/widget/kpi-orders.hbs create mode 100644 addon/components/widget/kpi-orders.js create mode 100644 addon/components/widget/kpi-revenue.hbs create mode 100644 addon/components/widget/kpi-revenue.js create mode 100644 addon/components/widget/kpi-tile.hbs create mode 100644 addon/components/widget/kpi-tile.js create mode 100644 addon/components/widget/orders-by-status.hbs create mode 100644 addon/components/widget/orders-by-status.js create mode 100644 addon/components/widget/revenue-trend.hbs create mode 100644 addon/components/widget/revenue-trend.js create mode 100644 addon/components/widget/top-products.hbs create mode 100644 addon/components/widget/top-products.js create mode 100644 app/components/widget/customer-insights.js create mode 100644 app/components/widget/kpi-active-orders.js create mode 100644 app/components/widget/kpi-aov.js create mode 100644 app/components/widget/kpi-cart-conversion.js create mode 100644 app/components/widget/kpi-customers.js create mode 100644 app/components/widget/kpi-orders.js create mode 100644 app/components/widget/kpi-revenue.js create mode 100644 app/components/widget/kpi-tile.js create mode 100644 app/components/widget/orders-by-status.js create mode 100644 app/components/widget/revenue-trend.js create mode 100644 app/components/widget/top-products.js create mode 100644 server/src/Http/Controllers/AnalyticsController.php create mode 100644 tests/integration/components/widget/kpi-tile-test.js create mode 100644 tests/integration/components/widget/top-products-test.js diff --git a/addon/components/widget/customer-insights.hbs b/addon/components/widget/customer-insights.hbs new file mode 100644 index 0000000..494688e --- /dev/null +++ b/addon/components/widget/customer-insights.hbs @@ -0,0 +1,44 @@ +
+
+
+
Customer Insights
+
New vs returning buyers
+
+
+ +
+ {{#if this.error}} +
{{this.error}}
+ {{else if this.load.isRunning}} +
+ {{else if this.data}} +
+
+ New + {{this.data.new_customers}} +
+
+ Returning + {{this.data.returning_customers}} +
+
+ +
+
+ Repeat purchase rate + {{this.data.repeat_rate}}% +
+
+
+
+
+ +
+ {{this.data.total_customers}} buyers ordered during this period. +
+ {{else}} +
No customer activity yet.
+ {{/if}} +
+
diff --git a/addon/components/widget/customer-insights.js b/addon/components/widget/customer-insights.js new file mode 100644 index 0000000..1de9e4b --- /dev/null +++ b/addon/components/widget/customer-insights.js @@ -0,0 +1,42 @@ +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 WidgetCustomerInsightsComponent extends Component { + static widgetId = 'storefront-customer-insights-widget'; + + @service fetch; + @service storefront; + + @tracked data = null; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + this.storefront.on('order.broadcasted', () => { + this.load.perform(); + }); + this.storefront.on('storefront.changed', () => { + this.load.perform(); + }); + } + + get storeId() { + return this.storefront.activeStore?.public_id ?? this.storefront.activeStore?.id; + } + + get returningPercentStyle() { + return `width: ${this.data?.repeat_rate ?? 0}%`; + } + + @task *load() { + try { + this.data = yield this.fetch.get('analytics/customer-insights', { store: this.storeId }, { namespace: 'storefront/int/v1' }); + this.error = null; + } catch (error) { + this.error = error?.message ?? 'Unable to load customer insights'; + } + } +} diff --git a/addon/components/widget/customers.hbs b/addon/components/widget/customers.hbs index ddd28ad..b508d27 100644 --- a/addon/components/widget/customers.hbs +++ b/addon/components/widget/customers.hbs @@ -6,10 +6,10 @@ @open={{this.loaded}} @isLoading={{this.loadCustomers.isRunning}} @pad={{false}} - @wrapperClass={{@wrapperClass}} - @panelBodyClass="p-0i" + @wrapperClass={{concat @wrapperClass " storefront-dashboard-widget storefront-customers-widget h-full"}} + @panelBodyClass="p-0i storefront-widget-panel-body" > -
+
{{#if (media "isMobile")}} @@ -27,10 +27,12 @@
+ {{else}} +
No recent customers yet.
{{/each}} {{else}} -
+
@@ -52,9 +54,13 @@ + {{else}} + + + {{/each}}
{{n-a customer.orders 0}}
No recent customers yet.
{{/if}} - \ No newline at end of file + diff --git a/addon/components/widget/kpi-active-orders.hbs b/addon/components/widget/kpi-active-orders.hbs new file mode 100644 index 0000000..20a58b5 --- /dev/null +++ b/addon/components/widget/kpi-active-orders.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-active-orders.js b/addon/components/widget/kpi-active-orders.js new file mode 100644 index 0000000..7738e04 --- /dev/null +++ b/addon/components/widget/kpi-active-orders.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiActiveOrdersComponent extends Component { + static widgetId = 'storefront-kpi-active-orders-widget'; +} diff --git a/addon/components/widget/kpi-aov.hbs b/addon/components/widget/kpi-aov.hbs new file mode 100644 index 0000000..d79dd07 --- /dev/null +++ b/addon/components/widget/kpi-aov.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-aov.js b/addon/components/widget/kpi-aov.js new file mode 100644 index 0000000..245e42d --- /dev/null +++ b/addon/components/widget/kpi-aov.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiAovComponent extends Component { + static widgetId = 'storefront-kpi-aov-widget'; +} diff --git a/addon/components/widget/kpi-cart-conversion.hbs b/addon/components/widget/kpi-cart-conversion.hbs new file mode 100644 index 0000000..82a7a23 --- /dev/null +++ b/addon/components/widget/kpi-cart-conversion.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-cart-conversion.js b/addon/components/widget/kpi-cart-conversion.js new file mode 100644 index 0000000..2c36d86 --- /dev/null +++ b/addon/components/widget/kpi-cart-conversion.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiCartConversionComponent extends Component { + static widgetId = 'storefront-kpi-cart-conversion-widget'; +} diff --git a/addon/components/widget/kpi-customers.hbs b/addon/components/widget/kpi-customers.hbs new file mode 100644 index 0000000..c8b1dfc --- /dev/null +++ b/addon/components/widget/kpi-customers.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-customers.js b/addon/components/widget/kpi-customers.js new file mode 100644 index 0000000..b9d6e88 --- /dev/null +++ b/addon/components/widget/kpi-customers.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiCustomersComponent extends Component { + static widgetId = 'storefront-kpi-customers-widget'; +} diff --git a/addon/components/widget/kpi-orders.hbs b/addon/components/widget/kpi-orders.hbs new file mode 100644 index 0000000..ff0857d --- /dev/null +++ b/addon/components/widget/kpi-orders.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-orders.js b/addon/components/widget/kpi-orders.js new file mode 100644 index 0000000..a2f0396 --- /dev/null +++ b/addon/components/widget/kpi-orders.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiOrdersComponent extends Component { + static widgetId = 'storefront-kpi-orders-widget'; +} diff --git a/addon/components/widget/kpi-revenue.hbs b/addon/components/widget/kpi-revenue.hbs new file mode 100644 index 0000000..c510f31 --- /dev/null +++ b/addon/components/widget/kpi-revenue.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-revenue.js b/addon/components/widget/kpi-revenue.js new file mode 100644 index 0000000..98e6cf1 --- /dev/null +++ b/addon/components/widget/kpi-revenue.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiRevenueComponent extends Component { + static widgetId = 'storefront-kpi-revenue-widget'; +} diff --git a/addon/components/widget/kpi-tile.hbs b/addon/components/widget/kpi-tile.hbs new file mode 100644 index 0000000..a3cd6c1 --- /dev/null +++ b/addon/components/widget/kpi-tile.hbs @@ -0,0 +1,27 @@ +
+
+
+
+
{{this.title}}
+ {{#if this.error}} +
{{this.error}}
+ {{else if this.load.isRunning}} +
+ {{else}} +
{{this.formattedValue}}
+ {{/if}} +
+
+ +
+
+ +
+ {{or @period "vs previous period"}} + + + {{this.deltaText}} + +
+
+
diff --git a/addon/components/widget/kpi-tile.js b/addon/components/widget/kpi-tile.js new file mode 100644 index 0000000..43babbf --- /dev/null +++ b/addon/components/widget/kpi-tile.js @@ -0,0 +1,91 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import formatCurrency from '@fleetbase/ember-ui/utils/format-currency'; + +export default class WidgetKpiTileComponent extends Component { + @service fetch; + @service storefront; + + @tracked data = null; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + this.storefront.on('order.broadcasted', () => { + this.load.perform(); + }); + this.storefront.on('storefront.changed', () => { + this.load.perform(); + }); + } + + get storeId() { + return this.storefront.activeStore?.public_id ?? this.storefront.activeStore?.id; + } + + get metric() { + return this.data?.metrics?.[this.args.metric] ?? {}; + } + + get title() { + return this.args.title ?? 'Metric'; + } + + get formattedValue() { + const value = this.metric.value ?? 0; + + if (this.metric.format === 'money') { + return formatCurrency(value, this.metric.currency ?? this.data?.currency ?? 'USD'); + } + + if (this.metric.format === 'percent') { + return `${value}%`; + } + + return Number(value).toLocaleString(); + } + + get deltaText() { + const delta = this.metric.delta_percent; + if (typeof delta !== 'number') { + return '0%'; + } + + return `${delta > 0 ? '+' : ''}${delta}%`; + } + + get deltaDirection() { + const delta = this.metric.delta_percent ?? 0; + if (delta === 0) { + return 'neutral'; + } + + const isGood = this.metric.inverse ? delta < 0 : delta > 0; + + return isGood ? 'good' : 'bad'; + } + + get deltaIcon() { + if (this.deltaDirection === 'neutral') { + return 'minus'; + } + + return (this.metric.delta_percent ?? 0) > 0 ? 'arrow-up' : 'arrow-down'; + } + + get accentClass() { + return `storefront-kpi-accent-${this.args.accent ?? 'blue'} storefront-kpi-trend-${this.deltaDirection}`; + } + + @task *load() { + try { + this.data = yield this.fetch.get('analytics/overview', { store: this.storeId }, { namespace: 'storefront/int/v1' }); + this.error = null; + } catch (error) { + this.error = error?.message ?? 'Unable to load metric'; + } + } +} diff --git a/addon/components/widget/orders-by-status.hbs b/addon/components/widget/orders-by-status.hbs new file mode 100644 index 0000000..6b4bfd7 --- /dev/null +++ b/addon/components/widget/orders-by-status.hbs @@ -0,0 +1,21 @@ +
+
+
+
Order Status Mix
+
{{or this.data.total 0}} orders this period
+
+
+ +
+ {{#if this.error}} +
{{this.error}}
+ {{else if this.load.isRunning}} +
+ {{else if this.hasData}} + + {{else}} +
No order activity yet.
+ {{/if}} +
+
diff --git a/addon/components/widget/orders-by-status.js b/addon/components/widget/orders-by-status.js new file mode 100644 index 0000000..96b31f8 --- /dev/null +++ b/addon/components/widget/orders-by-status.js @@ -0,0 +1,57 @@ +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 WidgetOrdersByStatusComponent extends Component { + static widgetId = 'storefront-orders-by-status-widget'; + + @service fetch; + @service storefront; + + @tracked data = null; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + this.storefront.on('order.broadcasted', () => { + this.load.perform(); + }); + this.storefront.on('storefront.changed', () => { + this.load.perform(); + }); + } + + get storeId() { + return this.storefront.activeStore?.public_id ?? this.storefront.activeStore?.id; + } + + get hasData() { + return (this.data?.total ?? 0) > 0; + } + + get chartOptions() { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { mode: 'index', intersect: false }, + }, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: true, ticks: { precision: 0 } }, + }, + }; + } + + @task *load() { + try { + this.data = yield this.fetch.get('analytics/orders-by-status', { store: this.storeId }, { namespace: 'storefront/int/v1' }); + this.error = null; + } catch (error) { + this.error = error?.message ?? 'Unable to load order status mix'; + } + } +} diff --git a/addon/components/widget/orders.hbs b/addon/components/widget/orders.hbs index d5bc96c..c645bf2 100644 --- a/addon/components/widget/orders.hbs +++ b/addon/components/widget/orders.hbs @@ -1,15 +1,15 @@ -
+
@@ -208,10 +208,12 @@
+ {{else}} +
No recent orders yet.
{{/each}} {{else}} -
+
@@ -294,14 +296,20 @@ + {{else}} + + + {{/each}} - - - - + {{#if this.orders.length}} + + + + + {{/if}}
No recent orders yet.
TOTAL
{{format-currency this.total this.currency}}
TOTAL
{{format-currency this.total this.currency}}
{{/if}} - \ No newline at end of file + diff --git a/addon/components/widget/orders.js b/addon/components/widget/orders.js index 928b7e2..5118439 100644 --- a/addon/components/widget/orders.js +++ b/addon/components/widget/orders.js @@ -73,7 +73,7 @@ export default class WidgetOrdersComponent extends Component { calculateTotal(orders = []) { let total = 0; orders.forEach((order) => { - total += get(order, 'meta.total'); + total += Number(get(order, 'meta.total') ?? 0); }); return total; diff --git a/addon/components/widget/revenue-trend.hbs b/addon/components/widget/revenue-trend.hbs new file mode 100644 index 0000000..de47b8f --- /dev/null +++ b/addon/components/widget/revenue-trend.hbs @@ -0,0 +1,25 @@ +
+
+
+
Revenue Trend
+
{{this.formattedRevenue}} across {{this.data.summary.orders}} orders
+
+
+ {{#each this.periods as |period|}} + + {{/each}} +
+
+ +
+ {{#if this.error}} +
{{this.error}}
+ {{else if this.load.isRunning}} +
+ {{else if this.data}} + + {{else}} +
No revenue data yet.
+ {{/if}} +
+
diff --git a/addon/components/widget/revenue-trend.js b/addon/components/widget/revenue-trend.js new file mode 100644 index 0000000..a735425 --- /dev/null +++ b/addon/components/widget/revenue-trend.js @@ -0,0 +1,88 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import formatCurrency from '@fleetbase/ember-ui/utils/format-currency'; + +const PERIODS = [ + { label: '7d', days: 7 }, + { label: '30d', days: 30 }, + { label: '90d', days: 90 }, +]; + +export default class WidgetRevenueTrendComponent extends Component { + static widgetId = 'storefront-revenue-trend-widget'; + + @service fetch; + @service storefront; + + @tracked data = null; + @tracked error = null; + @tracked period = PERIODS[1]; + + periods = PERIODS; + + constructor() { + super(...arguments); + this.load.perform(); + this.storefront.on('order.broadcasted', () => { + this.load.perform(); + }); + this.storefront.on('storefront.changed', () => { + this.load.perform(); + }); + } + + get storeId() { + return this.storefront.activeStore?.public_id ?? this.storefront.activeStore?.id; + } + + get queryParams() { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - (this.period.days - 1)); + + return { + store: this.storeId, + start: start.toISOString().slice(0, 10), + end: end.toISOString().slice(0, 10), + }; + } + + get formattedRevenue() { + return formatCurrency(this.data?.summary?.revenue ?? 0, this.data?.summary?.currency ?? 'USD'); + } + + get chartOptions() { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8 } }, + tooltip: { mode: 'index', intersect: false }, + }, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: true, ticks: { precision: 0 } }, + y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false }, ticks: { precision: 0 } }, + }, + elements: { point: { radius: 0, hoverRadius: 4 } }, + }; + } + + @task *load() { + try { + this.data = yield this.fetch.get('analytics/revenue-trend', this.queryParams, { namespace: 'storefront/int/v1' }); + this.error = null; + } catch (error) { + this.error = error?.message ?? 'Unable to load revenue trend'; + } + } + + @action setPeriod(period) { + this.period = period; + this.load.perform(); + } +} diff --git a/addon/components/widget/top-products.hbs b/addon/components/widget/top-products.hbs new file mode 100644 index 0000000..b8a75c9 --- /dev/null +++ b/addon/components/widget/top-products.hbs @@ -0,0 +1,30 @@ +
+
+
+
Top Products
+
Best sellers by revenue
+
+
+ +
+ {{#if this.error}} +
{{this.error}}
+ {{else if this.load.isRunning}} +
+ {{else if this.products.length}} + {{#each this.products as |product index|}} +
+
{{add index 1}}
+
+
{{product.name}}
+
{{product.quantity}} sold
+
+
{{format-currency product.revenue product.currency}}
+
+ {{/each}} + {{else}} +
No product sales yet.
+ {{/if}} +
+
diff --git a/addon/components/widget/top-products.js b/addon/components/widget/top-products.js new file mode 100644 index 0000000..7357cd1 --- /dev/null +++ b/addon/components/widget/top-products.js @@ -0,0 +1,39 @@ +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 WidgetTopProductsComponent extends Component { + static widgetId = 'storefront-top-products-widget'; + + @service fetch; + @service storefront; + + @tracked products = []; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + this.storefront.on('order.broadcasted', () => { + this.load.perform(); + }); + this.storefront.on('storefront.changed', () => { + this.load.perform(); + }); + } + + get storeId() { + return this.storefront.activeStore?.public_id ?? this.storefront.activeStore?.id; + } + + @task *load() { + try { + const response = yield this.fetch.get('analytics/top-products', { store: this.storeId }, { namespace: 'storefront/int/v1' }); + this.products = response.products ?? []; + this.error = null; + } catch (error) { + this.error = error?.message ?? 'Unable to load top products'; + } + } +} diff --git a/addon/extension.js b/addon/extension.js index e5ea862..7af3040 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -3,13 +3,14 @@ import { Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; function createStorefrontKeyMetricsWidget() { return new Widget({ id: 'storefront-key-metrics-widget', - name: 'Storefront Metrics', - description: 'Key metrics from Storefront.', + name: 'Storefront Metrics (Legacy)', + description: 'Legacy grouped Storefront metrics.', icon: 'store', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/storefront-key-metrics'), grid_options: { w: 12, h: 7, minW: 8, minH: 7 }, options: { title: 'Storefront Metrics' }, - default: true, + category: 'Legacy', + default: false, }); } @@ -17,14 +18,115 @@ export function registerWidgets(widgetService) { widgetService.registerDashboard('storefront'); widgetService.registerWidgets('storefront', [ + new Widget({ + id: 'storefront-kpi-revenue-widget', + name: 'Revenue', + description: 'Storefront revenue for the current period with trend.', + icon: 'sack-dollar', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-revenue'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), + new Widget({ + id: 'storefront-kpi-orders-widget', + name: 'Orders', + description: 'Order volume for the current period with trend.', + icon: 'bag-shopping', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-orders'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), + new Widget({ + id: 'storefront-kpi-aov-widget', + name: 'Average Order Value', + description: 'Average order value for non-canceled Storefront orders.', + icon: 'receipt', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-aov'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), + new Widget({ + id: 'storefront-kpi-active-orders-widget', + name: 'Active Orders', + description: 'Orders currently moving through fulfillment.', + icon: 'bolt', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-active-orders'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), + new Widget({ + id: 'storefront-kpi-customers-widget', + name: 'Customers', + description: 'Unique customers ordering during the current period.', + icon: 'users', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-customers'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), + new Widget({ + id: 'storefront-kpi-cart-conversion-widget', + name: 'Cart Conversion', + description: 'Orders as a percentage of carts created in the current period.', + icon: 'cart-shopping', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-cart-conversion'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), + new Widget({ + id: 'storefront-revenue-trend-widget', + name: 'Revenue Trend', + description: 'Revenue and order volume over time.', + icon: 'chart-line', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/revenue-trend'), + grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + category: 'Analytics', + default: true, + }), + new Widget({ + id: 'storefront-orders-by-status-widget', + name: 'Order Status Mix', + description: 'Distribution of Storefront orders by status.', + icon: 'chart-column', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders-by-status'), + grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + category: 'Analytics', + default: true, + }), + new Widget({ + id: 'storefront-top-products-widget', + name: 'Top Products', + description: 'Best-selling products by revenue.', + icon: 'ranking-star', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/top-products'), + grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + category: 'Analytics', + default: true, + }), + new Widget({ + id: 'storefront-customer-insights-widget', + name: 'Customer Insights', + description: 'New and returning customer mix.', + icon: 'chart-pie', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/customer-insights'), + grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + category: 'Analytics', + default: true, + }), new Widget({ id: 'storefront-metrics-widget', - name: 'Storefront Metrics', - description: 'Storefront order, customer, store, and earnings metrics.', + name: 'Storefront Metrics (Legacy)', + description: 'Legacy Storefront order, customer, store, and earnings metrics.', icon: 'chart-line', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/storefront-metrics'), grid_options: { w: 12, h: 4, minW: 8, minH: 4 }, - default: true, + category: 'Legacy', + default: false, }), new Widget({ id: 'storefront-orders-widget', @@ -34,6 +136,7 @@ export function registerWidgets(widgetService) { component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders'), grid_options: { w: 12, h: 10, minW: 8, minH: 8 }, options: { wrapperClass: 'bordered-classic' }, + category: 'Operations', default: true, }), new Widget({ @@ -42,9 +145,10 @@ export function registerWidgets(widgetService) { description: 'Recent Storefront customers.', icon: 'users', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/customers'), - grid_options: { w: 12, h: 10, minW: 8, minH: 8 }, + grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, options: { wrapperClass: 'bordered-classic' }, - default: true, + category: 'Operations', + default: false, }), ]); diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index d3b0993..9768b7f 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -110,3 +110,328 @@ td.network-store-name-column > div > svg { .min-h-38px { min-height: 38px; } + +.storefront-dashboard-widget { + border-color: #e5e7eb; + background-color: #fff; +} + +.storefront-dashboard-page { + width: 100%; +} + +body[data-theme='dark'] .storefront-dashboard-widget { + border-color: #374151; + background-color: #1f2937; +} + +.storefront-dashboard-widget .ui-chart { + position: relative; + width: 100%; + height: 100%; + min-height: 0; +} + +.storefront-dashboard-widget .ui-chart > canvas { + position: absolute; + inset: 0; + width: 100% !important; + height: 100% !important; +} + +.storefront-kpi-tile { + transition: + transform 160ms ease-out, + box-shadow 160ms ease-out, + border-color 160ms ease-out; +} + +.storefront-kpi-tile:hover { + transform: translateY(-1px); + box-shadow: 0 8px 24px -12px rgba(15, 23, 42, 35%); +} + +body[data-theme='dark'] .storefront-kpi-tile:hover { + box-shadow: 0 8px 28px -10px rgba(2, 6, 23, 70%); +} + +.storefront-kpi-value { + color: #111827; + font-size: 1.55rem; + font-weight: 800; + line-height: 1.05; +} + +body[data-theme='dark'] .storefront-kpi-value { + color: #f9fafb; +} + +.storefront-kpi-icon { + width: 2rem; + height: 2rem; + color: #64748b; + background-color: rgba(100, 116, 139, 12%); +} + +.storefront-kpi-delta { + color: #475569; + background-color: rgba(100, 116, 139, 12%); +} + +.storefront-kpi-trend-good .storefront-kpi-delta { + color: #047857; + background-color: rgba(16, 185, 129, 12%); +} + +.storefront-kpi-trend-bad .storefront-kpi-delta { + color: #be123c; + background-color: rgba(244, 63, 94, 12%); +} + +.storefront-kpi-accent-green { + background-image: linear-gradient(135deg, rgba(16, 185, 129, 10%) 0%, rgba(16, 185, 129, 0%) 62%); + border-color: rgba(16, 185, 129, 28%); +} + +.storefront-kpi-accent-blue { + background-image: linear-gradient(135deg, rgba(59, 130, 246, 10%) 0%, rgba(59, 130, 246, 0%) 62%); + border-color: rgba(59, 130, 246, 28%); +} + +.storefront-kpi-accent-amber { + background-image: linear-gradient(135deg, rgba(245, 158, 11, 12%) 0%, rgba(245, 158, 11, 0%) 62%); + border-color: rgba(245, 158, 11, 32%); +} + +.storefront-kpi-accent-violet { + background-image: linear-gradient(135deg, rgba(139, 92, 246, 10%) 0%, rgba(139, 92, 246, 0%) 62%); + border-color: rgba(139, 92, 246, 26%); +} + +.storefront-kpi-accent-slate { + background-image: linear-gradient(135deg, rgba(100, 116, 139, 10%) 0%, rgba(100, 116, 139, 0%) 62%); +} + +body[data-theme='dark'] .storefront-kpi-accent-green, +body[data-theme='dark'] .storefront-kpi-accent-blue, +body[data-theme='dark'] .storefront-kpi-accent-amber, +body[data-theme='dark'] .storefront-kpi-accent-violet, +body[data-theme='dark'] .storefront-kpi-accent-slate { + background-color: #1f2937; +} + +.storefront-widget-header { + display: flex; + min-height: 3.25rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-bottom: 1px solid #e5e7eb; + padding: 0.65rem 0.85rem; +} + +body[data-theme='dark'] .storefront-widget-header { + border-bottom-color: #374151; +} + +.storefront-widget-title { + color: #111827; + font-size: 0.78rem; + font-weight: 800; + line-height: 1.1; +} + +body[data-theme='dark'] .storefront-widget-title { + color: #f9fafb; +} + +.storefront-widget-subtitle { + margin-top: 0.15rem; + color: #64748b; + font-size: 0.68rem; + font-weight: 600; +} + +body[data-theme='dark'] .storefront-widget-subtitle { + color: #9ca3af; +} + +.storefront-period-switcher { + display: inline-flex; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-period-switcher { + border-color: #374151; +} + +.storefront-period-switcher button { + min-width: 2.1rem; + padding: 0.28rem 0.45rem; + color: #64748b; + font-size: 0.68rem; + font-weight: 800; +} + +.storefront-period-switcher button.active { + color: #fff; + background-color: #2563eb; +} + +.storefront-widget-empty { + display: flex; + min-height: 8rem; + height: 100%; + align-items: center; + justify-content: center; + padding: 1rem; + color: #64748b; + font-size: 0.8rem; + font-weight: 700; + text-align: center; +} + +body[data-theme='dark'] .storefront-widget-empty { + color: #9ca3af; +} + +.storefront-widget-scroll { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: 0.35rem; +} + +.storefront-list-row { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 3.35rem; + border-radius: 0.375rem; + padding: 0.55rem 0.65rem; +} + +.storefront-list-row:hover { + background-color: #f8fafc; +} + +body[data-theme='dark'] .storefront-list-row:hover { + background-color: #111827; +} + +.storefront-rank { + display: flex; + width: 1.6rem; + height: 1.6rem; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + color: #2563eb; + background-color: rgba(37, 99, 235, 10%); + font-size: 0.72rem; + font-weight: 800; +} + +.storefront-row-title { + color: #111827; + font-size: 0.78rem; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-row-title { + color: #f9fafb; +} + +.storefront-row-subtitle { + color: #64748b; + font-size: 0.68rem; + font-weight: 600; +} + +.storefront-row-value { + color: #047857; + font-size: 0.78rem; + font-weight: 800; + white-space: nowrap; +} + +.storefront-mini-stat { + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + padding: 0.75rem; + background-color: #f8fafc; +} + +body[data-theme='dark'] .storefront-mini-stat { + border-color: #374151; + background-color: #111827; +} + +.storefront-mini-stat span { + display: block; + color: #64748b; + font-size: 0.68rem; + font-weight: 800; + text-transform: uppercase; +} + +.storefront-mini-stat strong { + display: block; + margin-top: 0.35rem; + color: #111827; + font-size: 1.55rem; + font-weight: 800; + line-height: 1; +} + +body[data-theme='dark'] .storefront-mini-stat strong { + color: #f9fafb; +} + +.storefront-widget-panel-body { + display: flex; + min-height: 0; + flex-direction: column; +} + +.storefront-table-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e5e7eb; +} + +body[data-theme='dark'] .storefront-table-toolbar { + border-bottom-color: #374151; +} + +.storefront-table-wrapper { + min-height: 0; + flex: 1 1 auto; + overflow: auto; +} + +.storefront-widget-table th, +.storefront-widget-table td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-widget-table th { + color: #64748b; + font-size: 0.68rem; + font-weight: 800; + text-transform: uppercase; +} + +.storefront-widget-table td { + font-size: 0.75rem; +} diff --git a/addon/templates/home.hbs b/addon/templates/home.hbs index c977668..55cce2e 100644 --- a/addon/templates/home.hbs +++ b/addon/templates/home.hbs @@ -1,6 +1,5 @@ - - - - + + + {{outlet}} diff --git a/app/components/widget/customer-insights.js b/app/components/widget/customer-insights.js new file mode 100644 index 0000000..fba6be0 --- /dev/null +++ b/app/components/widget/customer-insights.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/customer-insights'; diff --git a/app/components/widget/kpi-active-orders.js b/app/components/widget/kpi-active-orders.js new file mode 100644 index 0000000..0c95e48 --- /dev/null +++ b/app/components/widget/kpi-active-orders.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-active-orders'; diff --git a/app/components/widget/kpi-aov.js b/app/components/widget/kpi-aov.js new file mode 100644 index 0000000..7428305 --- /dev/null +++ b/app/components/widget/kpi-aov.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-aov'; diff --git a/app/components/widget/kpi-cart-conversion.js b/app/components/widget/kpi-cart-conversion.js new file mode 100644 index 0000000..fc480ad --- /dev/null +++ b/app/components/widget/kpi-cart-conversion.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-cart-conversion'; diff --git a/app/components/widget/kpi-customers.js b/app/components/widget/kpi-customers.js new file mode 100644 index 0000000..79d630d --- /dev/null +++ b/app/components/widget/kpi-customers.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-customers'; diff --git a/app/components/widget/kpi-orders.js b/app/components/widget/kpi-orders.js new file mode 100644 index 0000000..7a30606 --- /dev/null +++ b/app/components/widget/kpi-orders.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-orders'; diff --git a/app/components/widget/kpi-revenue.js b/app/components/widget/kpi-revenue.js new file mode 100644 index 0000000..d72f87d --- /dev/null +++ b/app/components/widget/kpi-revenue.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-revenue'; diff --git a/app/components/widget/kpi-tile.js b/app/components/widget/kpi-tile.js new file mode 100644 index 0000000..ea5ba17 --- /dev/null +++ b/app/components/widget/kpi-tile.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-tile'; diff --git a/app/components/widget/orders-by-status.js b/app/components/widget/orders-by-status.js new file mode 100644 index 0000000..674604b --- /dev/null +++ b/app/components/widget/orders-by-status.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/orders-by-status'; diff --git a/app/components/widget/revenue-trend.js b/app/components/widget/revenue-trend.js new file mode 100644 index 0000000..23effd9 --- /dev/null +++ b/app/components/widget/revenue-trend.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/revenue-trend'; diff --git a/app/components/widget/top-products.js b/app/components/widget/top-products.js new file mode 100644 index 0000000..f03bc6e --- /dev/null +++ b/app/components/widget/top-products.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/top-products'; diff --git a/server/src/Http/Controllers/AnalyticsController.php b/server/src/Http/Controllers/AnalyticsController.php new file mode 100644 index 0000000..10b60af --- /dev/null +++ b/server/src/Http/Controllers/AnalyticsController.php @@ -0,0 +1,327 @@ +dateRanges($request); + $store = $this->resolveStore($request); + $companyUuid = $this->companyUuid($request); + + $currentOrders = $this->orders($companyUuid, $start, $end, $store)->get(); + $previousOrders = $this->orders($companyUuid, $previousStart, $previousEnd, $store)->get(); + $currency = $store->currency ?? data_get($currentOrders->first(), 'meta.currency', 'USD'); + $currentRevenue = $this->sumOrderRevenue($currentOrders); + $previousRevenue = $this->sumOrderRevenue($previousOrders); + $currentOrderCount = $currentOrders->whereNotIn('status', self::CANCELED_STATUSES)->count(); + $previousOrderCount = $previousOrders->whereNotIn('status', self::CANCELED_STATUSES)->count(); + $completedOrders = $currentOrders->where('status', 'completed')->count(); + $activeOrders = $currentOrders->whereNotIn('status', array_merge(self::CANCELED_STATUSES, ['completed']))->count(); + $currentCustomers = $currentOrders->whereNotNull('customer_uuid')->pluck('customer_uuid')->unique()->count(); + $previousCustomers = $previousOrders->whereNotNull('customer_uuid')->pluck('customer_uuid')->unique()->count(); + $currentAov = $currentOrderCount > 0 ? round($currentRevenue / $currentOrderCount, 2) : 0; + $previousAov = $previousOrderCount > 0 ? round($previousRevenue / $previousOrderCount, 2) : 0; + $canceledOrders = $currentOrders->whereIn('status', self::CANCELED_STATUSES)->count(); + $previousCanceledOrders = $previousOrders->whereIn('status', self::CANCELED_STATUSES)->count(); + $cancellationRate = $currentOrders->count() > 0 ? round(($canceledOrders / $currentOrders->count()) * 100, 2) : 0; + $previousCancellationRate = $previousOrders->count() > 0 ? round(($previousCanceledOrders / $previousOrders->count()) * 100, 2) : 0; + $cartConversion = $this->cartConversion($companyUuid, $start, $end, $store, $currentOrderCount); + $previousCartConversion = $this->cartConversion($companyUuid, $previousStart, $previousEnd, $store, $previousOrderCount); + + return response()->json([ + 'period' => [ + 'start' => $start->toDateString(), + 'end' => $end->toDateString(), + ], + 'currency' => $currency, + 'metrics' => [ + 'revenue' => $this->metric($currentRevenue, $previousRevenue, 'money', $currency), + 'orders' => $this->metric($currentOrderCount, $previousOrderCount), + 'average_order_value' => $this->metric($currentAov, $previousAov, 'money', $currency), + 'active_orders' => $this->metric($activeOrders, $previousOrders->whereNotIn('status', array_merge(self::CANCELED_STATUSES, ['completed']))->count()), + 'completed_orders' => $this->metric($completedOrders, $previousOrders->where('status', 'completed')->count()), + 'customers' => $this->metric($currentCustomers, $previousCustomers), + 'stores' => $this->metric(Store::where('company_uuid', $companyUuid)->count(), Store::where('company_uuid', $companyUuid)->count()), + 'products' => $this->metric($this->productCount($companyUuid, $store), $this->productCount($companyUuid, $store)), + 'cart_conversion' => $this->metric($cartConversion, $previousCartConversion, 'percent'), + 'cancellation_rate' => $this->metric($cancellationRate, $previousCancellationRate, 'percent', null, true), + ], + ]); + } + + public function revenueTrend(Request $request) + { + [$start, $end] = $this->dateRanges($request); + $store = $this->resolveStore($request); + $companyUuid = $this->companyUuid($request); + $orders = $this->orders($companyUuid, $start, $end, $store)->get(); + $days = $this->days($start, $end); + $revenue = []; + $counts = []; + + foreach ($days as $date) { + $ordersForDay = $orders->filter(function ($order) use ($date) { + return Carbon::parse($order->created_at)->toDateString() === $date; + }); + $revenue[] = $this->sumOrderRevenue($ordersForDay); + $counts[] = $ordersForDay->whereNotIn('status', self::CANCELED_STATUSES)->count(); + } + + return response()->json([ + 'labels' => $days, + 'datasets' => [ + [ + 'label' => 'Revenue', + 'data' => $revenue, + 'borderColor' => '#10b981', + 'backgroundColor' => 'rgba(16, 185, 129, 0.16)', + 'fill' => true, + 'tension' => 0.35, + 'yAxisID' => 'y', + ], + [ + 'label' => 'Orders', + 'data' => $counts, + 'borderColor' => '#3b82f6', + 'backgroundColor' => 'rgba(59, 130, 246, 0.12)', + 'fill' => false, + 'tension' => 0.35, + 'yAxisID' => 'y1', + ], + ], + 'summary' => [ + 'revenue' => array_sum($revenue), + 'orders' => array_sum($counts), + 'currency' => $store->currency ?? data_get($orders->first(), 'meta.currency', 'USD'), + ], + ]); + } + + public function ordersByStatus(Request $request) + { + [$start, $end] = $this->dateRanges($request); + $orders = $this->orders($this->companyUuid($request), $start, $end, $this->resolveStore($request))->get(); + $groups = $orders->groupBy(function ($order) { + return $order->status ?: 'unknown'; + })->map->count()->sortDesc(); + + return response()->json([ + 'labels' => $groups->keys()->values(), + 'datasets' => [ + [ + 'label' => 'Orders', + 'data' => $groups->values(), + 'backgroundColor' => ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f43f5e', '#64748b'], + 'borderWidth' => 0, + ], + ], + 'total' => $orders->count(), + ]); + } + + public function topProducts(Request $request) + { + [$start, $end] = $this->dateRanges($request); + $store = $this->resolveStore($request); + $companyUuid = $this->companyUuid($request); + $products = []; + + $this->checkouts($companyUuid, $start, $end, $store)->get()->each(function ($checkout) use (&$products, $store) { + $items = data_get($checkout, 'cart_state.items', []); + foreach ($items as $item) { + if ($store && data_get($item, 'store_id') !== $store->public_id) { + continue; + } + + $productId = data_get($item, 'product_id'); + if (!$productId) { + continue; + } + + if (!isset($products[$productId])) { + $products[$productId] = [ + 'id' => $productId, + 'name' => data_get($item, 'name', $productId), + 'quantity' => 0, + 'revenue' => 0, + 'currency' => $checkout->currency, + ]; + } + + $products[$productId]['quantity'] += (int) data_get($item, 'quantity', 1); + $products[$productId]['revenue'] += (float) data_get($item, 'subtotal', 0); + } + }); + + $names = Product::whereIn('public_id', array_keys($products))->pluck('name', 'public_id'); + + return response()->json([ + 'products' => collect($products)->map(function ($product) use ($names) { + $product['name'] = $names[$product['id']] ?? $product['name']; + + return $product; + })->sortByDesc('revenue')->take(8)->values(), + ]); + } + + public function customerInsights(Request $request) + { + [$start, $end] = $this->dateRanges($request); + $store = $this->resolveStore($request); + $companyUuid = $this->companyUuid($request); + $orders = $this->orders($companyUuid, $start, $end, $store)->whereNotNull('customer_uuid')->get(); + $customerOrderCounts = $orders->groupBy('customer_uuid')->map->count(); + $returningCustomers = $customerOrderCounts->filter(function ($count, $customerUuid) use ($companyUuid, $start, $store) { + return $count > 1 || $this->orders($companyUuid, null, $start->copy()->subSecond(), $store)->where('customer_uuid', $customerUuid)->exists(); + })->count(); + $totalCustomers = $customerOrderCounts->count(); + $newCustomers = max($totalCustomers - $returningCustomers, 0); + $repeatRate = $totalCustomers > 0 ? round(($returningCustomers / $totalCustomers) * 100, 2) : 0; + + return response()->json([ + 'new_customers' => $newCustomers, + 'returning_customers' => $returningCustomers, + 'repeat_rate' => $repeatRate, + 'total_customers' => $totalCustomers, + 'known_customers' => Contact::where(['company_uuid' => $companyUuid, 'type' => 'customer'])->count(), + ]); + } + + private function dateRanges(Request $request): array + { + $end = $request->date('end') ? Carbon::parse($request->date('end'))->endOfDay() : Carbon::now()->endOfDay(); + $start = $request->date('start') ? Carbon::parse($request->date('start'))->startOfDay() : $end->copy()->subDays(29)->startOfDay(); + $seconds = max($start->diffInSeconds($end), 1); + $previousEnd = $start->copy()->subSecond(); + $previousStart = $previousEnd->copy()->subSeconds($seconds); + + return [$start, $end, $previousStart, $previousEnd]; + } + + private function companyUuid(Request $request): ?string + { + return session('company') ?? data_get($request->user(), 'company_uuid') ?? data_get($request->user(), 'company.uuid'); + } + + private function resolveStore(Request $request): ?Store + { + $store = $request->input('store') ?? $request->input('storefront'); + if (!$store) { + return null; + } + + return Store::where('uuid', $store)->orWhere('public_id', $store)->first(); + } + + private function orders(?string $companyUuid, ?Carbon $start = null, ?Carbon $end = null, ?Store $store = null) + { + $query = Order::where(['company_uuid' => $companyUuid, 'type' => 'storefront'])->whereNull('deleted_at'); + + if ($start && $end) { + $query->whereBetween('created_at', [$start, $end]); + } elseif ($end) { + $query->where('created_at', '<=', $end); + } + + if ($store) { + $query->where('meta->storefront_id', $store->public_id); + } + + return $query; + } + + private function checkouts(?string $companyUuid, Carbon $start, Carbon $end, ?Store $store = null) + { + $query = Checkout::where('company_uuid', $companyUuid) + ->whereBetween('created_at', [$start, $end]) + ->where(function ($query) { + $query->whereNotNull('order_uuid')->orWhere('captured', true); + }); + + if ($store) { + $query->where(function ($query) use ($store) { + $query->where('store_uuid', $store->uuid)->orWhere('cart_state->checkout_store_id', $store->public_id); + }); + } + + return $query; + } + + private function sumOrderRevenue(Collection $orders): float + { + return round($orders->whereNotIn('status', self::CANCELED_STATUSES)->sum(function ($order) { + return (float) data_get($order, 'meta.total', 0); + }), 2); + } + + private function productCount(?string $companyUuid, ?Store $store = null): int + { + $query = Product::where('company_uuid', $companyUuid); + if ($store) { + $query->where('store_uuid', $store->uuid); + } + + return $query->count(); + } + + private function cartConversion(?string $companyUuid, Carbon $start, Carbon $end, ?Store $store, int $orders): float + { + $carts = Cart::where('company_uuid', $companyUuid)->whereBetween('created_at', [$start, $end])->get(); + + if ($store) { + $carts = $carts->filter(function ($cart) use ($store) { + return collect($cart->items)->contains(function ($item) use ($store) { + return data_get($item, 'store_id') === $store->public_id; + }); + }); + } + + $cartCount = $carts->count(); + + return $cartCount > 0 ? round(($orders / $cartCount) * 100, 2) : 0; + } + + private function metric($current, $previous, string $format = 'number', ?string $currency = null, bool $inverse = false): array + { + $delta = $current - $previous; + $deltaPercent = $previous != 0 ? round(($delta / abs($previous)) * 100, 2) : ($current > 0 ? 100 : 0); + + return [ + 'value' => $current, + 'previous' => $previous, + 'delta' => $delta, + 'delta_percent' => $deltaPercent, + 'format' => $format, + 'currency' => $currency, + 'inverse' => $inverse, + ]; + } + + private function days(Carbon $start, Carbon $end): array + { + $days = []; + $cursor = $start->copy(); + + while ($cursor->lte($end)) { + $days[] = $cursor->toDateString(); + $cursor->addDay(); + } + + return $days; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index fa5d08f..57fede4 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -161,6 +161,16 @@ function ($router) { $router->post('send-push-notification', 'ActionController@sendPushNotification'); } ); + $router->group( + ['prefix' => 'analytics'], + function ($router) { + $router->get('overview', 'AnalyticsController@overview'); + $router->get('revenue-trend', 'AnalyticsController@revenueTrend'); + $router->get('orders-by-status', 'AnalyticsController@ordersByStatus'); + $router->get('top-products', 'AnalyticsController@topProducts'); + $router->get('customer-insights', 'AnalyticsController@customerInsights'); + } + ); $router->fleetbaseRoutes( 'orders', function ($router, $controller) { diff --git a/tests/integration/components/widget/kpi-tile-test.js b/tests/integration/components/widget/kpi-tile-test.js new file mode 100644 index 0000000..8c492d7 --- /dev/null +++ b/tests/integration/components/widget/kpi-tile-test.js @@ -0,0 +1,59 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +class StorefrontStubService extends Service { + activeStore = { public_id: 'store_1' }; + on() {} +} + +module('Integration | Component | widget/kpi-tile', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + }); + + test('it renders a loaded KPI value and trend', async function (assert) { + class FetchStubService extends Service { + get() { + return { + currency: 'USD', + metrics: { + revenue: { + value: 12500, + previous: 10000, + delta_percent: 25, + format: 'money', + currency: 'USD', + }, + }, + }; + } + } + + this.owner.register('service:fetch', FetchStubService); + + await render(hbs``); + + assert.dom('.storefront-kpi-tile').exists(); + assert.dom('.storefront-kpi-tile').includesText('Revenue'); + assert.dom('.storefront-kpi-delta').hasText('+25%'); + }); + + test('it renders an error state', async function (assert) { + class FetchStubService extends Service { + get() { + throw new Error('Metrics unavailable'); + } + } + + this.owner.register('service:fetch', FetchStubService); + + await render(hbs``); + + assert.dom('.storefront-kpi-tile').includesText('Metrics unavailable'); + }); +}); diff --git a/tests/integration/components/widget/top-products-test.js b/tests/integration/components/widget/top-products-test.js new file mode 100644 index 0000000..bb6000d --- /dev/null +++ b/tests/integration/components/widget/top-products-test.js @@ -0,0 +1,50 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +class StorefrontStubService extends Service { + activeStore = { public_id: 'store_1' }; + on() {} +} + +module('Integration | Component | widget/top-products', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + }); + + test('it renders top product rows', async function (assert) { + class FetchStubService extends Service { + get() { + return { + products: [{ id: 'product_1', name: 'Signature Bento', quantity: 7, revenue: 4900, currency: 'USD' }], + }; + } + } + + this.owner.register('service:fetch', FetchStubService); + + await render(hbs``); + + assert.dom('.storefront-list-row').exists({ count: 1 }); + assert.dom('.storefront-list-row').includesText('Signature Bento'); + assert.dom('.storefront-list-row').includesText('7 sold'); + }); + + test('it renders an empty state', async function (assert) { + class FetchStubService extends Service { + get() { + return { products: [] }; + } + } + + this.owner.register('service:fetch', FetchStubService); + + await render(hbs``); + + assert.dom('.storefront-widget-empty').hasText('No product sales yet.'); + }); +}); From b3126c716e95086637ec6a25084e573081e1f8e4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 16:39:03 +0800 Subject: [PATCH 07/14] Polish storefront dashboard layout --- addon/components/widget/customer-insights.hbs | 8 +- .../widget/kpi-cancellation-rate.hbs | 1 + .../widget/kpi-cancellation-rate.js | 5 + .../widget/kpi-completed-orders.hbs | 1 + .../components/widget/kpi-completed-orders.js | 5 + addon/components/widget/orders-by-status.hbs | 6 +- addon/components/widget/orders.hbs | 274 +++++---------- addon/components/widget/revenue-trend.hbs | 6 +- addon/components/widget/revenue-trend.js | 27 +- addon/components/widget/top-products.hbs | 2 +- addon/extension.js | 28 +- addon/styles/storefront-engine.css | 316 +++++++++++++++++- addon/templates/home.hbs | 10 +- .../widget/kpi-cancellation-rate.js | 1 + app/components/widget/kpi-completed-orders.js | 1 + .../widget/customer-insights-test.js | 39 +++ .../components/widget/kpi-tile-test.js | 26 ++ .../components/widget/orders-test.js | 50 ++- .../components/widget/revenue-trend-test.js | 57 ++++ tests/unit/extension-test.js | 41 ++- 20 files changed, 671 insertions(+), 233 deletions(-) create mode 100644 addon/components/widget/kpi-cancellation-rate.hbs create mode 100644 addon/components/widget/kpi-cancellation-rate.js create mode 100644 addon/components/widget/kpi-completed-orders.hbs create mode 100644 addon/components/widget/kpi-completed-orders.js create mode 100644 app/components/widget/kpi-cancellation-rate.js create mode 100644 app/components/widget/kpi-completed-orders.js create mode 100644 tests/integration/components/widget/customer-insights-test.js create mode 100644 tests/integration/components/widget/revenue-trend-test.js diff --git a/addon/components/widget/customer-insights.hbs b/addon/components/widget/customer-insights.hbs index 494688e..b0b98c3 100644 --- a/addon/components/widget/customer-insights.hbs +++ b/addon/components/widget/customer-insights.hbs @@ -7,13 +7,13 @@
-
+
{{#if this.error}}
{{this.error}}
{{else if this.load.isRunning}}
{{else if this.data}} -
+
New {{this.data.new_customers}} @@ -24,7 +24,7 @@
-
+
Repeat purchase rate {{this.data.repeat_rate}}% @@ -34,7 +34,7 @@
-
+
{{this.data.total_customers}} buyers ordered during this period.
{{else}} diff --git a/addon/components/widget/kpi-cancellation-rate.hbs b/addon/components/widget/kpi-cancellation-rate.hbs new file mode 100644 index 0000000..0dd36a1 --- /dev/null +++ b/addon/components/widget/kpi-cancellation-rate.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-cancellation-rate.js b/addon/components/widget/kpi-cancellation-rate.js new file mode 100644 index 0000000..f918f1d --- /dev/null +++ b/addon/components/widget/kpi-cancellation-rate.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiCancellationRateComponent extends Component { + static widgetId = 'storefront-kpi-cancellation-rate-widget'; +} diff --git a/addon/components/widget/kpi-completed-orders.hbs b/addon/components/widget/kpi-completed-orders.hbs new file mode 100644 index 0000000..2787178 --- /dev/null +++ b/addon/components/widget/kpi-completed-orders.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/widget/kpi-completed-orders.js b/addon/components/widget/kpi-completed-orders.js new file mode 100644 index 0000000..8b4b166 --- /dev/null +++ b/addon/components/widget/kpi-completed-orders.js @@ -0,0 +1,5 @@ +import Component from '@glimmer/component'; + +export default class WidgetKpiCompletedOrdersComponent extends Component { + static widgetId = 'storefront-kpi-completed-orders-widget'; +} diff --git a/addon/components/widget/orders-by-status.hbs b/addon/components/widget/orders-by-status.hbs index 6b4bfd7..7f4ba08 100644 --- a/addon/components/widget/orders-by-status.hbs +++ b/addon/components/widget/orders-by-status.hbs @@ -7,13 +7,15 @@
-
+
{{#if this.error}}
{{this.error}}
{{else if this.load.isRunning}}
{{else if this.hasData}} - +
+ +
{{else}}
No order activity yet.
{{/if}} diff --git a/addon/components/widget/orders.hbs b/addon/components/widget/orders.hbs index c645bf2..41be6b3 100644 --- a/addon/components/widget/orders.hbs +++ b/addon/components/widget/orders.hbs @@ -1,259 +1,141 @@ - -
-
+ {{#if (media "isMobile")}} -
+
{{#each this.orders as |order|}} -
-
-
- {{order.public_id}} -
{{order.createdAt}}
-
{{order.createdAgo}}
+
+
+
+ {{order.public_id}} +
{{order.createdAgo}}
-
- +
+
{{format-currency order.meta.total order.meta.currency}}
+ +
+
+ +
+ {{n-a order.customer.name}} + {{#if order.meta.is_pickup}} - {{t - "storefront.component.widget.orders.pickup-order" - }} + {{t "storefront.component.widget.orders.pickup-order"}} + {{else}} + {{n-a order.driver_assigned.name}} {{/if}} -
{{format-currency order.meta.total order.meta.currency}}
-
+
-
-
- -
- {{#unless order.meta.is_pickup}} -
-
{{t "storefront.component.modals.incoming-order.assigned"}}
-
- {{#if order.driver_assigned.id}} -
- {{order.driver_assigned.name}} -
-
{{n-a order.driver_assigned.displayName}}
-
{{n-a - order.driver_assigned.phone - (t "storefront.component.modals.incoming-order.no-phone") - }}
-
-
- {{else}} -
-
{{t "storefront.component.modals.incoming-order.not-assigned"}}
-
- {{/if}} -
-
-
-
- {{/unless}} -
-
{{t "storefront.common.customer"}}
-
-
-
- {{order.customer.name}} -
-
-
{{order.customer.name}}
-
{{order.customer.email}}
-
{{order.customer.phone}}
-
-
- {{#unless order.meta.is_pickup}} -
-
{{t "storefront.component.modals.incoming-order.address"}}
-
-
- -
-
- -
-
-
- {{/unless}} -
-
-
-
-
- {{t "storefront.component.widget.orders.subtotal"}} - {{format-currency order.meta.subtotal order.meta.currency}} -
- {{#unless order.meta.is_pickup}} -
- {{t "storefront.component.widget.orders.delivery-fee"}} - {{format-currency order.meta.delivery_fee order.meta.currency}} -
- {{/unless}} - {{#if order.meta.tip}} -
- {{t "storefront.component.widget.orders.tip"}} - {{get-tip-amount order.meta.tip order.meta.subtotal order.meta.currency}} -
- {{/if}} - {{#if order.meta.delivery_tip}} -
- {{t "storefront.component.widget.orders.delivery-tip"}} - {{get-tip-amount order.meta.delivery_tip order.meta.subtotal order.meta.currency}} -
- {{/if}} -
- {{t "storefront.component.widget.orders.total"}} - {{format-currency order.meta.total order.meta.currency}} -
-
-
-
+ {{else}}
No recent orders yet.
{{/each}}
{{else}} -
- +
+
- - - - - - - - + + + + + + + + {{#each this.orders as |order|}} - - - - + + + + - + + - {{else}} - + {{/each}} - - {{#if this.orders.length}} - - - - - {{/if}}
{{t "storefront.component.widget.orders.id-column"}}{{t "storefront.common.amount"}}{{t "storefront.common.customer"}}{{t "storefront.common.driver"}}{{t "storefront.common.created"}}{{t "storefront.common.status"}}
OrderAmountCustomerFulfillmentCreatedStatusActions
{{order.public_id}}{{format-currency order.meta.total order.meta.currency}}{{n-a order.customer.name}}
+ {{order.public_id}} + {{format-currency order.meta.total order.meta.currency}}{{n-a order.customer.name}} {{#if order.meta.is_pickup}} {{t "storefront.component.widget.orders.pickup-order" }} {{else}} - {{n-a order.driver_assigned.name}} + {{n-a order.driver_assigned.name}} {{/if}} {{order.createdAgo}}{{order.createdAgo}} - - -
+
+
No recent orders yet.
+
No recent orders yet.
+
TOTAL
{{format-currency this.total this.currency}}
{{/if}} - +
diff --git a/addon/components/widget/revenue-trend.hbs b/addon/components/widget/revenue-trend.hbs index de47b8f..755f05a 100644 --- a/addon/components/widget/revenue-trend.hbs +++ b/addon/components/widget/revenue-trend.hbs @@ -11,13 +11,15 @@
-
+
{{#if this.error}}
{{this.error}}
{{else if this.load.isRunning}}
{{else if this.data}} - +
+ +
{{else}}
No revenue data yet.
{{/if}} diff --git a/addon/components/widget/revenue-trend.js b/addon/components/widget/revenue-trend.js index a735425..fc534cb 100644 --- a/addon/components/widget/revenue-trend.js +++ b/addon/components/widget/revenue-trend.js @@ -60,13 +60,32 @@ export default class WidgetRevenueTrendComponent extends Component { maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { - legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8 } }, + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + pointStyle: 'circle', + boxWidth: 6, + boxHeight: 6, + padding: 10, + font: { size: 10, weight: '600' }, + }, + }, tooltip: { mode: 'index', intersect: false }, }, scales: { - x: { grid: { display: false } }, - y: { beginAtZero: true, ticks: { precision: 0 } }, - y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false }, ticks: { precision: 0 } }, + x: { + grid: { display: false }, + ticks: { + autoSkip: true, + maxTicksLimit: 6, + maxRotation: 0, + minRotation: 0, + font: { size: 10 }, + }, + }, + y: { beginAtZero: true, ticks: { precision: 0, font: { size: 10 } } }, + y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false }, ticks: { precision: 0, font: { size: 10 } } }, }, elements: { point: { radius: 0, hoverRadius: 4 } }, }; diff --git a/addon/components/widget/top-products.hbs b/addon/components/widget/top-products.hbs index b8a75c9..57809d1 100644 --- a/addon/components/widget/top-products.hbs +++ b/addon/components/widget/top-products.hbs @@ -7,7 +7,7 @@
-
+
{{#if this.error}}
{{this.error}}
{{else if this.load.isRunning}} diff --git a/addon/extension.js b/addon/extension.js index 7af3040..f3228f0 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -58,6 +58,16 @@ export function registerWidgets(widgetService) { category: 'KPI Tiles', default: true, }), + new Widget({ + id: 'storefront-kpi-completed-orders-widget', + name: 'Completed Orders', + description: 'Completed orders for the current period.', + icon: 'circle-check', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-completed-orders'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), new Widget({ id: 'storefront-kpi-customers-widget', name: 'Customers', @@ -78,13 +88,23 @@ export function registerWidgets(widgetService) { category: 'KPI Tiles', default: true, }), + new Widget({ + id: 'storefront-kpi-cancellation-rate-widget', + name: 'Cancellation Rate', + description: 'Canceled orders as a percentage of current period order volume.', + icon: 'ban', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/kpi-cancellation-rate'), + grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, + category: 'KPI Tiles', + default: true, + }), new Widget({ id: 'storefront-revenue-trend-widget', name: 'Revenue Trend', description: 'Revenue and order volume over time.', icon: 'chart-line', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/revenue-trend'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 6, h: 10, minW: 5, minH: 9 }, category: 'Analytics', default: true, }), @@ -94,7 +114,7 @@ export function registerWidgets(widgetService) { description: 'Distribution of Storefront orders by status.', icon: 'chart-column', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders-by-status'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 6, h: 9, minW: 5, minH: 8 }, category: 'Analytics', default: true, }), @@ -104,7 +124,7 @@ export function registerWidgets(widgetService) { description: 'Best-selling products by revenue.', icon: 'ranking-star', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/top-products'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 6, h: 9, minW: 5, minH: 8 }, category: 'Analytics', default: true, }), @@ -114,7 +134,7 @@ export function registerWidgets(widgetService) { description: 'New and returning customer mix.', icon: 'chart-pie', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/customer-insights'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 6, h: 9, minW: 5, minH: 8 }, category: 'Analytics', default: true, }), diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 9768b7f..aacbe7e 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -117,7 +117,52 @@ td.network-store-name-column > div > svg { } .storefront-dashboard-page { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; width: 100%; + padding: 1rem; + border-bottom: 1px solid #e5e7eb; + background-color: #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 6%); +} + +body[data-theme='dark'] .storefront-dashboard-page { + border-bottom-color: #1f2937; + background-color: #111827; + box-shadow: 0 1px 2px rgba(2, 6, 23, 30%); +} + +.storefront-dashboard-title h1 { + color: #111827; + font-size: 1.05rem; + font-weight: 800; + line-height: 1.2; +} + +body[data-theme='dark'] .storefront-dashboard-title h1 { + color: #f9fafb; +} + +.storefront-dashboard-actions { + justify-content: flex-end; +} + +.storefront-dashboard-create-wrapper { + padding: 1rem; +} + +@media (width <= 767px) { + .storefront-dashboard-page { + align-items: flex-start; + flex-direction: column; + } + + .storefront-dashboard-actions { + width: 100%; + justify-content: flex-start; + } } body[data-theme='dark'] .storefront-dashboard-widget { @@ -125,6 +170,10 @@ body[data-theme='dark'] .storefront-dashboard-widget { background-color: #1f2937; } +.storefront-chart-widget { + overflow: hidden; +} + .storefront-dashboard-widget .ui-chart { position: relative; width: 100%; @@ -132,6 +181,24 @@ body[data-theme='dark'] .storefront-dashboard-widget { min-height: 0; } +.storefront-chart-body { + display: flex; + height: auto; + min-height: 0; + min-width: 0; + flex: 1 1 auto; + align-items: stretch; + justify-content: stretch; + padding: 0.75rem; +} + +.storefront-chart-frame { + position: relative; + min-height: 0; + width: 100%; + flex: 1 1 auto; +} + .storefront-dashboard-widget .ui-chart > canvas { position: absolute; inset: 0; @@ -212,10 +279,16 @@ body[data-theme='dark'] .storefront-kpi-value { background-image: linear-gradient(135deg, rgba(100, 116, 139, 10%) 0%, rgba(100, 116, 139, 0%) 62%); } +.storefront-kpi-accent-rose { + background-image: linear-gradient(135deg, rgba(244, 63, 94, 10%) 0%, rgba(244, 63, 94, 0%) 62%); + border-color: rgba(244, 63, 94, 28%); +} + body[data-theme='dark'] .storefront-kpi-accent-green, body[data-theme='dark'] .storefront-kpi-accent-blue, body[data-theme='dark'] .storefront-kpi-accent-amber, body[data-theme='dark'] .storefront-kpi-accent-violet, +body[data-theme='dark'] .storefront-kpi-accent-rose, body[data-theme='dark'] .storefront-kpi-accent-slate { background-color: #1f2937; } @@ -304,6 +377,10 @@ body[data-theme='dark'] .storefront-widget-empty { padding: 0.35rem; } +.storefront-product-list { + padding: 0.55rem; +} + .storefront-list-row { display: flex; align-items: center; @@ -363,7 +440,7 @@ body[data-theme='dark'] .storefront-row-title { .storefront-mini-stat { border: 1px solid #e5e7eb; border-radius: 0.375rem; - padding: 0.75rem; + padding: 0.65rem 0.75rem; background-color: #f8fafc; } @@ -382,13 +459,45 @@ body[data-theme='dark'] .storefront-mini-stat { .storefront-mini-stat strong { display: block; - margin-top: 0.35rem; + margin-top: 0.3rem; color: #111827; - font-size: 1.55rem; + font-size: 1.45rem; font-weight: 800; line-height: 1; } +.storefront-customer-insights-body { + display: flex; + min-height: 0; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + padding: 0.85rem; +} + +.storefront-repeat-rate { + margin-top: 0.85rem; +} + +.storefront-insight-note { + margin-top: 0.85rem; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + padding: 0.65rem 0.75rem; + color: #64748b; + background-color: #f8fafc; + font-size: 0.72rem; + font-weight: 600; +} + +body[data-theme='dark'] .storefront-insight-note { + border-color: #374151; + color: #9ca3af; + background-color: #111827; +} + body[data-theme='dark'] .storefront-mini-stat strong { color: #f9fafb; } @@ -435,3 +544,204 @@ body[data-theme='dark'] .storefront-table-toolbar { .storefront-widget-table td { font-size: 0.75rem; } + +.storefront-orders-widget { + overflow: hidden; +} + +.storefront-orders-header { + display: flex; + min-height: 3.65rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid #e5e7eb; + padding: 0.75rem 1rem; +} + +body[data-theme='dark'] .storefront-orders-header { + border-bottom-color: #374151; +} + +.storefront-orders-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.6rem; +} + +.storefront-orders-table-wrap { + min-height: 0; + flex: 1 1 auto; + overflow: auto; +} + +.storefront-orders-table { + width: 100%; + min-width: 880px; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; +} + +.storefront-orders-table th { + position: sticky; + top: 0; + z-index: 1; + border-bottom: 1px solid #e5e7eb; + color: #64748b; + background-color: #f8fafc; + font-size: 0.66rem; + font-weight: 800; + letter-spacing: 0; + line-height: 1; + padding: 0.75rem 1rem; + text-align: left; + text-transform: uppercase; +} + +body[data-theme='dark'] .storefront-orders-table th { + border-bottom-color: #374151; + color: #9ca3af; + background-color: #111827; +} + +.storefront-orders-table th:nth-child(1) { + width: 14%; +} + +.storefront-orders-table th:nth-child(2) { + width: 12%; +} + +.storefront-orders-table th:nth-child(3) { + width: 16%; +} + +.storefront-orders-table th:nth-child(4) { + width: 16%; +} + +.storefront-orders-table th:nth-child(5) { + width: 12%; +} + +.storefront-orders-table th:nth-child(6) { + width: 14%; +} + +.storefront-orders-table th:nth-child(7) { + width: 16%; +} + +.storefront-orders-table td { + border-bottom: 1px solid #eef2f7; + color: #111827; + font-size: 0.76rem; + font-weight: 600; + padding: 0.72rem 1rem; + vertical-align: middle; +} + +body[data-theme='dark'] .storefront-orders-table td { + border-bottom-color: #2d3748; + color: #f9fafb; +} + +.storefront-orders-table tbody tr:hover td { + background-color: #f8fafc; +} + +body[data-theme='dark'] .storefront-orders-table tbody tr:hover td { + background-color: #172033; +} + +.storefront-order-id { + color: #2563eb; + font-weight: 800; +} + +body[data-theme='dark'] .storefront-order-id { + color: #93c5fd; +} + +.storefront-order-total { + color: #047857; + font-weight: 800; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-order-total { + color: #34d399; +} + +.storefront-table-text, +.storefront-table-muted { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-table-muted { + color: #64748b; +} + +body[data-theme='dark'] .storefront-table-muted { + color: #9ca3af; +} + +.storefront-table-actions { + display: flex; + justify-content: flex-end; + gap: 0.35rem; +} + +.storefront-orders-mobile-list { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: 0.75rem; +} + +.storefront-order-card { + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + padding: 0.85rem; + background-color: #fff; +} + +.storefront-order-card + .storefront-order-card { + margin-top: 0.75rem; +} + +body[data-theme='dark'] .storefront-order-card { + border-color: #374151; + background-color: #111827; +} + +.storefront-order-meta, +.storefront-order-card-details { + color: #64748b; + font-size: 0.72rem; + font-weight: 600; +} + +body[data-theme='dark'] .storefront-order-meta, +body[data-theme='dark'] .storefront-order-card-details { + color: #9ca3af; +} + +.storefront-order-card-details { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.storefront-order-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.8rem; +} diff --git a/addon/templates/home.hbs b/addon/templates/home.hbs index 55cce2e..8b501dd 100644 --- a/addon/templates/home.hbs +++ b/addon/templates/home.hbs @@ -1,5 +1,13 @@ - + {{outlet}} diff --git a/app/components/widget/kpi-cancellation-rate.js b/app/components/widget/kpi-cancellation-rate.js new file mode 100644 index 0000000..a1ba6df --- /dev/null +++ b/app/components/widget/kpi-cancellation-rate.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-cancellation-rate'; diff --git a/app/components/widget/kpi-completed-orders.js b/app/components/widget/kpi-completed-orders.js new file mode 100644 index 0000000..b45a5b0 --- /dev/null +++ b/app/components/widget/kpi-completed-orders.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/widget/kpi-completed-orders'; diff --git a/tests/integration/components/widget/customer-insights-test.js b/tests/integration/components/widget/customer-insights-test.js new file mode 100644 index 0000000..2b4ff60 --- /dev/null +++ b/tests/integration/components/widget/customer-insights-test.js @@ -0,0 +1,39 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +class StorefrontStubService extends Service { + activeStore = { public_id: 'store_1' }; + on() {} +} + +module('Integration | Component | widget/customer-insights', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + }); + + test('it renders compact customer insight content', async function (assert) { + class FetchStubService extends Service { + get() { + return { + new_customers: 2, + returning_customers: 3, + repeat_rate: 60, + total_customers: 5, + }; + } + } + + this.owner.register('service:fetch', FetchStubService); + + await render(hbs``); + + assert.dom('.storefront-customer-insights-body').exists(); + assert.dom('.storefront-repeat-rate').includesText('Repeat purchase rate'); + assert.dom('.storefront-insight-note').hasText('5 buyers ordered during this period.'); + }); +}); diff --git a/tests/integration/components/widget/kpi-tile-test.js b/tests/integration/components/widget/kpi-tile-test.js index 8c492d7..53edfab 100644 --- a/tests/integration/components/widget/kpi-tile-test.js +++ b/tests/integration/components/widget/kpi-tile-test.js @@ -56,4 +56,30 @@ module('Integration | Component | widget/kpi-tile', function (hooks) { assert.dom('.storefront-kpi-tile').includesText('Metrics unavailable'); }); + + test('it renders inverse trend styling for cancellation rate', async function (assert) { + class FetchStubService extends Service { + get() { + return { + metrics: { + cancellation_rate: { + value: 8, + previous: 4, + delta_percent: 100, + format: 'percent', + inverse: true, + }, + }, + }; + } + } + + this.owner.register('service:fetch', FetchStubService); + + await render(hbs``); + + assert.dom('.storefront-kpi-tile').hasClass('storefront-kpi-accent-rose'); + assert.dom('.storefront-kpi-tile').hasClass('storefront-kpi-trend-bad'); + assert.dom('.storefront-kpi-delta').hasText('+100%'); + }); }); diff --git a/tests/integration/components/widget/orders-test.js b/tests/integration/components/widget/orders-test.js index 1dc9897..a9ee62c 100644 --- a/tests/integration/components/widget/orders-test.js +++ b/tests/integration/components/widget/orders-test.js @@ -1,26 +1,50 @@ +import Service from '@ember/service'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +class StorefrontStubService extends Service { + activeStore = { public_id: 'store_1', currency: 'USD' }; + on() {} +} + +class FetchStubService extends Service { + get() { + return []; + } +} + +class IntlStubService extends Service { + t(key) { + if (key === 'storefront.component.widget.orders.widget-title') { + return 'Recent Orders'; + } + + return key; + } +} + +class AppCacheStubService extends Service { + setEmberData() {} +} + module('Integration | Component | widget/orders', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + this.owner.register('service:fetch', FetchStubService); + this.owner.register('service:intl', IntlStubService); + this.owner.register('service:app-cache', AppCacheStubService); + }); + test('it renders the polished empty table shell', async function (assert) { await render(hbs``); - assert.dom(this.element).hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom(this.element).hasText('template block text'); + assert.dom('.storefront-orders-widget').exists(); + assert.dom('.storefront-orders-header').includesText('Recent Orders'); + assert.dom('.storefront-orders-table').exists(); + assert.dom('.storefront-widget-empty').hasText('No recent orders yet.'); }); }); diff --git a/tests/integration/components/widget/revenue-trend-test.js b/tests/integration/components/widget/revenue-trend-test.js new file mode 100644 index 0000000..7b3c504 --- /dev/null +++ b/tests/integration/components/widget/revenue-trend-test.js @@ -0,0 +1,57 @@ +import Component from '@glimmer/component'; +import Service from '@ember/service'; +import { setComponentTemplate } from '@ember/component'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +class StorefrontStubService extends Service { + activeStore = { public_id: 'store_1' }; + on() {} +} + +class FetchStubService extends Service { + get() { + return { + labels: ['2026-05-01', '2026-05-02'], + datasets: [{ label: 'Revenue', data: [10, 20] }], + summary: { revenue: 30, orders: 2, currency: 'USD' }, + }; + } +} + +class ChartStubComponent extends Component { + get legendBoxWidth() { + return this.args.options.plugins.legend.labels.boxWidth; + } + + get maxTicksLimit() { + return this.args.options.scales.x.ticks.maxTicksLimit; + } + + get xTickFontSize() { + return this.args.options.scales.x.ticks.font.size; + } +} + +module('Integration | Component | widget/revenue-trend', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + this.owner.register('service:fetch', FetchStubService); + this.owner.register( + 'component:chart', + setComponentTemplate(hbs`
{{this.legendBoxWidth}}/{{this.maxTicksLimit}}/{{this.xTickFontSize}}
`, ChartStubComponent) + ); + }); + + test('it passes compact chart options to the chart', async function (assert) { + await render(hbs``); + + assert.dom('.storefront-chart-body').exists(); + assert.dom('.storefront-chart-frame').exists(); + assert.dom('.chart-stub').hasText('6/6/10'); + }); +}); diff --git a/tests/unit/extension-test.js b/tests/unit/extension-test.js index 5d52b53..6a2cd9a 100644 --- a/tests/unit/extension-test.js +++ b/tests/unit/extension-test.js @@ -22,9 +22,46 @@ module('Unit | Extension', function () { assert.deepEqual(dashboards, ['storefront']); assert.deepEqual( storefrontRegistration.widgets.map((widget) => widget.id), - ['storefront-metrics-widget', 'storefront-orders-widget', 'storefront-customers-widget'] + [ + 'storefront-kpi-revenue-widget', + 'storefront-kpi-orders-widget', + 'storefront-kpi-aov-widget', + 'storefront-kpi-active-orders-widget', + 'storefront-kpi-completed-orders-widget', + 'storefront-kpi-customers-widget', + 'storefront-kpi-cart-conversion-widget', + 'storefront-kpi-cancellation-rate-widget', + 'storefront-revenue-trend-widget', + 'storefront-orders-by-status-widget', + 'storefront-top-products-widget', + 'storefront-customer-insights-widget', + 'storefront-metrics-widget', + 'storefront-orders-widget', + 'storefront-customers-widget', + ] ); - assert.true(storefrontRegistration.widgets.every((widget) => widget.default)); + assert.deepEqual( + storefrontRegistration.widgets.filter((widget) => widget.default).map((widget) => widget.id), + [ + 'storefront-kpi-revenue-widget', + 'storefront-kpi-orders-widget', + 'storefront-kpi-aov-widget', + 'storefront-kpi-active-orders-widget', + 'storefront-kpi-completed-orders-widget', + 'storefront-kpi-customers-widget', + 'storefront-kpi-cart-conversion-widget', + 'storefront-kpi-cancellation-rate-widget', + 'storefront-revenue-trend-widget', + 'storefront-orders-by-status-widget', + 'storefront-top-products-widget', + 'storefront-customer-insights-widget', + 'storefront-orders-widget', + ] + ); + assert.deepEqual(storefrontRegistration.widgets.find((widget) => widget.id === 'storefront-revenue-trend-widget').grid_options, { w: 6, h: 10, minW: 5, minH: 9 }); + assert.deepEqual(storefrontRegistration.widgets.find((widget) => widget.id === 'storefront-orders-by-status-widget').grid_options, { w: 6, h: 9, minW: 5, minH: 8 }); + assert.deepEqual(storefrontRegistration.widgets.find((widget) => widget.id === 'storefront-top-products-widget').grid_options, { w: 6, h: 9, minW: 5, minH: 8 }); + assert.deepEqual(storefrontRegistration.widgets.find((widget) => widget.id === 'storefront-customer-insights-widget').grid_options, { w: 6, h: 9, minW: 5, minH: 8 }); assert.deepEqual( dashboardRegistration.widgets.map((widget) => widget.id), ['storefront-key-metrics-widget'] From 4db5714d06566feb077070775daedbc38cfd7697 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 17:20:37 +0800 Subject: [PATCH 08/14] Add Storefront demo data seeders --- .../Testing/CatalogAndProductsSeeder.php | 243 ++++++++++ .../seeders/Testing/CheckoutOrdersSeeder.php | 457 ++++++++++++++++++ .../Testing/Concerns/SeedsTestingData.php | 212 ++++++++ server/seeders/Testing/TestingSeeder.php | 36 ++ .../src/Http/Controllers/OrderController.php | 14 + server/src/Http/Resources/Index/Order.php | 25 + 6 files changed, 987 insertions(+) create mode 100644 server/seeders/Testing/CatalogAndProductsSeeder.php create mode 100644 server/seeders/Testing/CheckoutOrdersSeeder.php create mode 100644 server/seeders/Testing/Concerns/SeedsTestingData.php create mode 100644 server/seeders/Testing/TestingSeeder.php create mode 100644 server/src/Http/Resources/Index/Order.php diff --git a/server/seeders/Testing/CatalogAndProductsSeeder.php b/server/seeders/Testing/CatalogAndProductsSeeder.php new file mode 100644 index 0000000..6c054b4 --- /dev/null +++ b/server/seeders/Testing/CatalogAndProductsSeeder.php @@ -0,0 +1,243 @@ +prepareCompany(); + if (!$company) { + return; + } + + $this->seedStorefront($company); + } + + public function purgeSeedData(): void + { + $storeUuids = $this->seededUuids(Store::class); + $productUuids = $this->seededUuids(Product::class); + $productVariantUuids = $this->seededUuids(ProductVariant::class); + $catalogCategoryUuids = $this->seededUuids(CatalogCategory::class); + $addonCategoryUuids = $this->seededUuids(AddonCategory::class); + + $this->deleteFrom($this->storefrontConnection(), 'product_variant_options', fn ($query) => $query->whereIn('product_variant_uuid', $productVariantUuids)->orWhere('meta->seed', static::SEED_NAME)); + $this->purgeModel(ProductVariant::class); + $this->deleteFrom($this->storefrontConnection(), 'product_addon_categories', fn ($query) => $query->whereIn('product_uuid', $productUuids)->orWhereIn('category_uuid', $addonCategoryUuids)); + $this->deleteFrom($this->storefrontConnection(), 'product_addons', fn ($query) => $query->whereIn('category_uuid', $addonCategoryUuids)); + $this->deleteFrom($this->storefrontConnection(), 'catalog_category_products', fn ($query) => $query->whereIn('catalog_category_uuid', $catalogCategoryUuids)->orWhereIn('product_uuid', $productUuids)); + $this->purgeModel(Product::class); + $this->purgeModel(Catalog::class); + $this->purgeModel(CatalogCategory::class); + $this->purgeModel(AddonCategory::class); + $this->deleteFrom($this->storefrontConnection(), 'store_locations', fn ($query) => $query->whereIn('store_uuid', $storeUuids)); + $this->purgeModel(Store::class); + } + + protected function seedStorefront(Company $company): void + { + $userUuid = session('user'); + $orderConfig = Storefront::createStorefrontConfig($company); + $store = $this->createRecord(Store::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => $userUuid, + 'order_config_uuid' => $orderConfig->uuid, + 'online' => true, + 'name' => 'Fleetbase Market', + 'description' => 'Local demo storefront for testing commerce-to-logistics workflows.', + 'email' => 'market@example.test', + 'phone' => '+1 555 0100', + 'website' => 'https://example.test/fleetbase-market', + 'tags' => ['demo', 'groceries', 'local'], + 'currency' => 'USD', + 'timezone' => 'Asia/Singapore', + 'pod_method' => 'scan', + 'options' => [ + 'auto_accept_orders' => false, + 'auto_dispatch' => false, + 'require_pod' => true, + ], + 'meta' => $this->meta('store:fleetbase-market'), + ]); + + $categories = [ + 'produce' => $this->createStoreCategory($company, $store, 'Fresh Produce', 'Fruit and vegetables for local delivery.'), + 'pantry' => $this->createStoreCategory($company, $store, 'Pantry Staples', 'Shelf-stable goods and household essentials.'), + ]; + + $products = [ + 'orchard-box' => $this->createProduct($company, $store, $categories['produce'], 'Orchard Fruit Box', 'Seasonal fruit selection packed for same-day delivery.', 2850, ['fruit', 'fresh'], true), + 'market-veg' => $this->createProduct($company, $store, $categories['produce'], 'Market Vegetable Bundle', 'Weekly vegetable bundle with leafy greens and root vegetables.', 2250, ['vegetables', 'bundle'], false), + 'coffee-kit' => $this->createProduct($company, $store, $categories['pantry'], 'Cold Brew Starter Kit', 'Coffee, filters, and syrup for storefront pickup or delivery.', 3400, ['coffee', 'beverage'], true), + 'rice-pack' => $this->createProduct($company, $store, $categories['pantry'], 'Jasmine Rice Pack', 'Premium jasmine rice in a delivery-friendly pack.', 1890, ['rice', 'pantry'], false), + ]; + + $addonCategory = $this->createAddonCategory($company, 'Gift Options', 'Optional packaging and notes for demo products.'); + $giftWrap = $this->createAddon($addonCategory, 'Gift Wrap', 'Reusable kraft gift wrap.', 350); + $noteCard = $this->createAddon($addonCategory, 'Note Card', 'Handwritten note card.', 150); + + foreach ([$products['orchard-box'], $products['coffee-kit']] as $product) { + $this->createRecord(ProductAddonCategory::class, [ + 'product_uuid' => $product->uuid, + 'category_uuid' => $addonCategory->uuid, + 'excluded_addons' => [], + 'max_selectable' => 2, + 'is_required' => false, + ]); + } + + $this->createVariant($products['orchard-box'], 'Box Size', true, false, [ + ['Small', 0], + ['Family', 1200], + ]); + $this->createVariant($products['coffee-kit'], 'Grind', true, false, [ + ['Whole Bean', 0], + ['Coarse Ground', 0], + ]); + + $catalog = $this->createRecord(Catalog::class, [ + 'store_uuid' => $store->uuid, + 'company_uuid' => $company->uuid, + 'created_by_uuid' => $userUuid, + 'name' => 'Everyday Delivery Catalog', + 'description' => 'Demo catalog containing products used by Storefront local QA.', + 'status' => 'published', + 'meta' => $this->meta('catalog:everyday-delivery'), + ]); + + $this->createCatalogCategory($company, $catalog, 'Fresh Picks', [$products['orchard-box'], $products['market-veg']]); + $this->createCatalogCategory($company, $catalog, 'Pantry', [$products['coffee-kit'], $products['rice-pack']]); + + unset($giftWrap, $noteCard); + } + + protected function createStoreCategory(Company $company, Store $store, string $name, string $description): Category + { + return $this->createRecord(Category::class, [ + 'company_uuid' => $company->uuid, + 'owner_uuid' => $store->uuid, + 'owner_type' => Utils::getMutationType('storefront:store'), + 'for' => 'storefront_product', + 'name' => $name, + 'description' => $description, + 'meta' => $this->meta('category:' . str($name)->slug()), + ]); + } + + protected function createProduct(Company $company, Store $store, Category $category, string $name, string $description, int $price, array $tags, bool $recommended): Product + { + return $this->createRecord(Product::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'store_uuid' => $store->uuid, + 'category_uuid' => $category->uuid, + 'name' => $name, + 'description' => $description, + 'tags' => $tags, + 'meta' => $this->meta('product:' . str($name)->slug()), + 'sku' => 'SF-DEMO-' . strtoupper(str($name)->slug('-')), + 'price' => $price, + 'currency' => 'USD', + 'sale_price' => 0, + 'is_service' => false, + 'is_bookable' => false, + 'is_available' => true, + 'is_on_sale' => false, + 'is_recommended' => $recommended, + 'can_pickup' => true, + 'status' => ProductStatus::AVAILABLE, + ]); + } + + protected function createAddonCategory(Company $company, string $name, string $description): AddonCategory + { + return $this->createRecord(AddonCategory::class, [ + 'company_uuid' => $company->uuid, + 'for' => 'storefront_product_addon', + 'name' => $name, + 'description' => $description, + 'meta' => $this->meta('addon-category:' . str($name)->slug()), + ]); + } + + protected function createAddon(AddonCategory $category, string $name, string $description, int $price): ProductAddon + { + return $this->createRecord(ProductAddon::class, [ + 'created_by_uuid' => session('user'), + 'category_uuid' => $category->uuid, + 'name' => $name, + 'description' => $description, + 'price' => $price, + 'sale_price' => 0, + 'is_on_sale' => false, + ]); + } + + protected function createVariant(Product $product, string $name, bool $required, bool $multiselect, array $options): ProductVariant + { + $variant = $this->createRecord(ProductVariant::class, [ + 'product_uuid' => $product->uuid, + 'name' => $name, + 'description' => $name . ' options', + 'meta' => $this->meta('variant:' . str($product->name . '-' . $name)->slug()), + 'is_required' => $required, + 'is_multiselect' => $multiselect, + 'min' => $required ? 1 : 0, + 'max' => $multiselect ? count($options) : 1, + ]); + + foreach ($options as [$optionName, $additionalCost]) { + $this->createRecord(ProductVariantOption::class, [ + 'product_variant_uuid' => $variant->uuid, + 'name' => $optionName, + 'description' => $optionName, + 'meta' => $this->meta('variant-option:' . str($product->name . '-' . $name . '-' . $optionName)->slug()), + 'additional_cost' => $additionalCost, + ]); + } + + return $variant; + } + + protected function createCatalogCategory(Company $company, Catalog $catalog, string $name, array $products): CatalogCategory + { + $category = $this->createRecord(CatalogCategory::class, [ + 'company_uuid' => $company->uuid, + 'owner_uuid' => $catalog->uuid, + 'owner_type' => Utils::getMutationType('storefront:catalog'), + 'for' => 'storefront_catalog', + 'name' => $name, + 'meta' => $this->meta('catalog-category:' . str($name)->slug()), + ]); + + foreach ($products as $product) { + $this->createRecord(CatalogProduct::class, [ + 'catalog_category_uuid' => $category->uuid, + 'product_uuid' => $product->uuid, + ]); + } + + return $category; + } +} diff --git a/server/seeders/Testing/CheckoutOrdersSeeder.php b/server/seeders/Testing/CheckoutOrdersSeeder.php new file mode 100644 index 0000000..7da841a --- /dev/null +++ b/server/seeders/Testing/CheckoutOrdersSeeder.php @@ -0,0 +1,457 @@ +prepareCompany(); + if (!$company) { + return; + } + + $store = $this->seededModel(Store::class, 'store:fleetbase-market'); + if (!$store) { + $this->call(CatalogAndProductsSeeder::class); + $store = $this->seededModel(Store::class, 'store:fleetbase-market'); + } + + if (!$store) { + $this->command?->error('No Storefront demo store was available for checkout/order seeding.'); + + return; + } + + $products = Product::where('meta->seed', static::SEED_NAME)->where('store_uuid', $store->uuid)->get()->keyBy(fn (Product $product) => data_get($product, 'meta.seed_id')); + if ($products->isEmpty()) { + $this->command?->error('No Storefront demo products were available for checkout/order seeding.'); + + return; + } + + $customers = collect($this->customerFixtures())->mapWithKeys(function (array $fixture, int $index) use ($company) { + [$name, $email, $phone] = $fixture; + + return [$index => $this->createCustomer($company, $name, $email, $phone)]; + })->all(); + $productLines = $products->values()->all(); + + $this->createOpenCart($company, $store, $customers[0], [ + [$products->get('product:orchard-fruit-box'), 1], + [$products->get('product:cold-brew-starter-kit'), 1], + ], 'active-cart'); + + $this->createPendingCheckout($company, $store, $customers[1], [ + [$products->get('product:market-vegetable-bundle'), 2], + ], 'pending-checkout'); + + for ($i = 1; $i <= 30; $i++) { + $customer = $customers[($i - 1) % count($customers)]; + $first = $productLines[($i - 1) % count($productLines)]; + $second = $productLines[$i % count($productLines)]; + $isPickup = $i % 5 === 0; + $deliveryFee = $isPickup ? 0 : 500 + (($i % 4) * 125); + + $this->createCompletedOrder($company, $store, $customer, [ + [$first, ($i % 3) + 1], + [$second, ($i % 2) + 1], + ], 'completed-order-' . str_pad((string) $i, 2, '0', STR_PAD_LEFT), $isPickup, $deliveryFee, $i); + } + } + + public function purgeSeedData(): void + { + $checkoutUuids = $this->seededUuids(Checkout::class); + $cartUuids = $this->seededUuids(Cart::class); + $transactionUuids = $this->seededUuids(Transaction::class); + + $this->deleteFrom($this->fleetbaseConnection(), 'transaction_items', fn ($query) => $query->whereIn('transaction_uuid', $transactionUuids)->orWhere('meta->seed', static::SEED_NAME)); + $this->purgeModel(Entity::class); + $this->purgeModel(Order::class); + $this->purgeModel(ServiceQuote::class); + $this->purgeModel(Payload::class); + $this->purgeModel(Transaction::class); + $this->purgeModel(Place::class); + + DB::connection($this->storefrontConnection())->table('carts') + ->whereIn('uuid', $cartUuids) + ->orWhereIn('checkout_uuid', $checkoutUuids) + ->update(['checkout_uuid' => null]); + DB::connection($this->storefrontConnection())->table('checkouts') + ->whereIn('uuid', $checkoutUuids) + ->orWhereIn('cart_uuid', $cartUuids) + ->update(['cart_uuid' => null, 'order_uuid' => null]); + + $this->deleteFrom($this->storefrontConnection(), 'carts', fn ($query) => $query->whereIn('uuid', $cartUuids)); + $this->deleteFrom($this->storefrontConnection(), 'checkouts', fn ($query) => $query->whereIn('uuid', $checkoutUuids)); + $this->purgeModel(Contact::class); + } + + protected function customerFixtures(): array + { + return [ + ['Ava Chen', 'ava.chen@example.test', '+1 555 0201'], + ['Ben Ortiz', 'ben.ortiz@example.test', '+1 555 0202'], + ['Mia Brooks', 'mia.brooks@example.test', '+1 555 0203'], + ['Noah Patel', 'noah.patel@example.test', '+1 555 0204'], + ['Emma Johnson', 'emma.johnson@example.test', '+1 555 0205'], + ['Liam Garcia', 'liam.garcia@example.test', '+1 555 0206'], + ['Olivia Smith', 'olivia.smith@example.test', '+1 555 0207'], + ['Lucas Brown', 'lucas.brown@example.test', '+1 555 0208'], + ['Sophia Davis', 'sophia.davis@example.test', '+1 555 0209'], + ['Ethan Wilson', 'ethan.wilson@example.test', '+1 555 0210'], + ['Amelia Martinez', 'amelia.martinez@example.test', '+1 555 0211'], + ['Mason Lee', 'mason.lee@example.test', '+1 555 0212'], + ['Isabella Taylor', 'isabella.taylor@example.test', '+1 555 0213'], + ['James Anderson', 'james.anderson@example.test', '+1 555 0214'], + ['Charlotte Thomas', 'charlotte.thomas@example.test', '+1 555 0215'], + ['Henry Moore', 'henry.moore@example.test', '+1 555 0216'], + ['Harper Jackson', 'harper.jackson@example.test', '+1 555 0217'], + ['Alexander White', 'alexander.white@example.test', '+1 555 0218'], + ['Evelyn Harris', 'evelyn.harris@example.test', '+1 555 0219'], + ['Daniel Martin', 'daniel.martin@example.test', '+1 555 0220'], + ]; + } + + protected function createCustomer(Company $company, string $name, string $email, string $phone): Contact + { + $seedId = 'customer:' . str($name)->slug(); + + return $this->createRecord(Contact::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'name' => $name, + 'email' => $email, + 'phone' => $phone, + 'type' => 'customer', + 'notes' => 'Storefront demo customer.', + 'meta' => $this->meta($seedId), + ]); + } + + protected function createOpenCart(Company $company, Store $store, Contact $customer, array $lines, string $seedId): Cart + { + return $this->createCart($company, $store, $customer, $lines, $seedId, [ + [ + 'code' => 'cart_created', + 'message' => 'Demo cart created.', + 'created_at' => $this->timestamp(1)->toISOString(), + ], + ]); + } + + protected function createPendingCheckout(Company $company, Store $store, Contact $customer, array $lines, string $seedId): Checkout + { + $cart = $this->createCart($company, $store, $customer, $lines, $seedId . ':cart', [ + [ + 'code' => 'checkout_initialized', + 'message' => 'Demo checkout initialized.', + 'created_at' => $this->timestamp(2)->toISOString(), + ], + ]); + + $checkout = $this->createRecord(Checkout::class, [ + 'company_uuid' => $company->uuid, + 'store_uuid' => $store->uuid, + 'cart_uuid' => $cart->uuid, + 'owner_uuid' => $customer->uuid, + 'owner_type' => FleetOpsUtils::getMutationType('fleet-ops:contact'), + 'amount' => $cart->subtotal, + 'currency' => 'USD', + 'is_cod' => true, + 'is_pickup' => false, + 'options' => $this->meta($seedId), + 'cart_state' => $cart->items, + 'captured' => false, + ]); + + $cart->update(['checkout_uuid' => $checkout->uuid]); + + return $checkout; + } + + protected function createCompletedOrder(Company $company, Store $store, Contact $customer, array $lines, string $seedId, bool $pickup, int $deliveryFee, int $sequence): Order + { + $cart = $this->createCart($company, $store, $customer, $lines, $seedId . ':cart', [ + [ + 'code' => 'checkout_captured', + 'message' => 'Demo checkout captured.', + 'created_at' => $this->timestamp(3)->toISOString(), + ], + ]); + + $total = $cart->subtotal + $deliveryFee; + + $serviceQuote = $pickup ? null : $this->createServiceQuote($company, $seedId, $deliveryFee); + + $checkout = $this->createRecord(Checkout::class, [ + 'company_uuid' => $company->uuid, + 'store_uuid' => $store->uuid, + 'cart_uuid' => $cart->uuid, + 'service_quote_uuid' => $serviceQuote?->uuid, + 'owner_uuid' => $customer->uuid, + 'owner_type' => 'fleet-ops:contact', + 'amount' => $total, + 'currency' => 'USD', + 'is_cod' => true, + 'is_pickup' => $pickup, + 'options' => $this->meta($seedId), + 'cart_state' => $cart->items, + 'captured' => true, + ]); + + $pickupPlace = $this->createPlace($company, $store->name, '100 Market Street', 'Singapore', 'SG', 1.2835, 103.8515, $seedId . ':pickup'); + $dropoff = $this->createPlace($company, $customer->name, $pickup ? '100 Market Street' : '18 Orchard Road', 'Singapore', 'SG', 1.3048, 103.8318, $seedId . ':dropoff'); + + $payload = $this->createRecord(Payload::class, [ + '_key' => $this->fixtureKey($seedId . ':payload'), + 'company_uuid' => $company->uuid, + 'pickup_uuid' => $pickupPlace->uuid, + 'dropoff_uuid' => $dropoff->uuid, + 'return_uuid' => $pickupPlace->uuid, + 'payment_method' => 'cash', + 'cod_amount' => $total, + 'cod_currency' => 'USD', + 'type' => 'storefront', + 'meta' => $this->meta($seedId . ':payload'), + ]); + + if ($serviceQuote) { + $serviceQuote->update(['payload_uuid' => $payload->uuid]); + } + + $this->createEntities($company, $payload, $customer, $cart, $dropoff, $seedId); + + $transaction = $this->createRecord(Transaction::class, [ + 'company_uuid' => $company->uuid, + 'customer_uuid' => $customer->uuid, + 'customer_type' => FleetOpsUtils::getMutationType('fleet-ops:contact'), + 'gateway_transaction_id' => 'sf-demo-' . str($seedId)->slug('-'), + 'gateway' => 'cash', + 'amount' => $total, + 'net_amount' => $total, + 'currency' => 'USD', + 'description' => 'Storefront demo order', + 'type' => 'storefront', + 'direction' => 'credit', + 'status' => 'success', + 'meta' => $this->meta($seedId . ':transaction', [ + 'storefront' => $store->name, + 'storefront_id' => $store->public_id, + ]), + ]); + + $this->createTransactionItems($transaction, $cart, $deliveryFee); + + $order = $this->createRecord(Order::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'payload_uuid' => $payload->uuid, + 'customer_uuid' => $customer->uuid, + 'customer_type' => FleetOpsUtils::getMutationType('fleet-ops:contact'), + 'transaction_uuid' => $transaction->uuid, + 'order_config_uuid' => $store->getOrderConfigId(), + 'adhoc' => false, + 'type' => 'storefront', + 'status' => $this->orderStatus($sequence, $pickup), + 'meta' => $this->meta($seedId, [ + 'storefront' => $store->name, + 'storefront_id' => $store->public_id, + 'checkout_id' => $checkout->public_id, + 'subtotal' => $cart->subtotal, + 'delivery_fee' => $deliveryFee, + 'tip' => null, + 'delivery_tip' => null, + 'total' => $total, + 'currency' => 'USD', + 'gateway' => 'cash', + 'require_pod' => true, + 'pod_method' => $store->pod_method, + 'is_pickup' => $pickup, + ]), + 'notes' => 'Seeded Storefront demo order.', + ]); + + $checkout->update(['order_uuid' => $order->uuid]); + $cart->update(['checkout_uuid' => $checkout->uuid]); + + return $order; + } + + protected function createServiceQuote(Company $company, string $seedId, int $deliveryFee): ServiceQuote + { + return $this->createRecord(ServiceQuote::class, [ + '_key' => $this->fixtureKey($seedId . ':service-quote'), + 'company_uuid' => $company->uuid, + 'amount' => $deliveryFee, + 'currency' => 'USD', + 'meta' => $this->meta($seedId . ':service-quote', [ + 'origin' => [ + 'name' => 'Fleetbase Market', + 'street1' => '100 Market Street', + 'city' => 'Singapore', + 'country' => 'SG', + 'location' => ['latitude' => 1.2835, 'longitude' => 103.8515], + ], + 'destination' => [ + 'name' => 'Customer Address', + 'street1' => '18 Orchard Road', + 'city' => 'Singapore', + 'country' => 'SG', + 'location' => ['latitude' => 1.3048, 'longitude' => 103.8318], + ], + ]), + 'expired_at' => now()->addDays(14), + ]); + } + + protected function orderStatus(int $sequence, bool $pickup): string + { + if ($pickup) { + return $sequence % 2 === 0 ? 'pickup_ready' : 'created'; + } + + return match ($sequence % 6) { + 0 => 'completed', + 1 => 'created', + 2 => 'preparing', + 3 => 'dispatched', + 4 => 'started', + default => 'ready', + }; + } + + protected function createCart(Company $company, Store $store, Contact $customer, array $lines, string $seedId, array $events): Cart + { + $items = collect($lines)->filter(fn ($line) => $line[0] instanceof Product)->map(function ($line) use ($store) { + /** @var Product $product */ + [$product, $quantity] = $line; + $subtotal = (int) $product->price * (int) $quantity; + + return [ + 'id' => $product->public_id, + 'product_id' => $product->public_id, + 'store_id' => $store->public_id, + 'name' => $product->name, + 'sku' => $product->sku, + 'price' => (int) $product->price, + 'currency' => $product->currency, + 'quantity' => (int) $quantity, + 'subtotal' => $subtotal, + 'variants' => [], + 'addons' => [], + ]; + })->values()->all(); + + return $this->createRecord(Cart::class, [ + 'company_uuid' => $company->uuid, + 'customer_id' => $customer->public_id, + 'unique_identifier' => $this->fixtureKey($seedId), + 'currency' => 'USD', + 'items' => $items, + 'events' => $events, + 'expires_at' => now()->addDays(14), + ]); + } + + protected function createPlace(Company $company, string $name, string $street, string $city, string $country, float $lat, float $lng, string $seedId): Place + { + return $this->createRecord(Place::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'name' => $name, + 'type' => 'storefront', + 'street1' => $street, + 'city' => $city, + 'country' => $country, + 'location' => new Point($lat, $lng), + 'meta' => $this->meta($seedId), + ]); + } + + protected function createEntities(Company $company, Payload $payload, Contact $customer, Cart $cart, Place $destination, string $seedId): void + { + foreach ($cart->items as $index => $item) { + $this->createRecord(Entity::class, [ + '_key' => $this->fixtureKey($seedId . ':entity:' . ($index + 1)), + 'company_uuid' => $company->uuid, + 'payload_uuid' => $payload->uuid, + 'customer_uuid' => $customer->uuid, + 'customer_type' => FleetOpsUtils::getMutationType('fleet-ops:contact'), + 'destination_uuid' => $destination->uuid, + 'internal_id' => data_get($item, 'product_id'), + 'name' => data_get($item, 'name'), + 'type' => 'storefront_product', + 'description' => 'Storefront demo order item.', + 'currency' => data_get($item, 'currency', 'USD'), + 'sku' => data_get($item, 'sku'), + 'price' => data_get($item, 'price'), + 'sale_price' => 0, + 'meta' => $this->meta($seedId . ':entity:' . ($index + 1), [ + 'product_id' => data_get($item, 'product_id'), + 'variants' => data_get($item, 'variants', []), + 'addons' => data_get($item, 'addons', []), + 'subtotal' => data_get($item, 'subtotal'), + 'quantity' => data_get($item, 'quantity'), + 'scheduled_at' => data_get($item, 'scheduled_at'), + ]), + ], true); + } + } + + protected function createTransactionItems(Transaction $transaction, Cart $cart, int $deliveryFee): void + { + foreach ($cart->items as $index => $item) { + $this->createRecord(TransactionItem::class, [ + 'transaction_uuid' => $transaction->uuid, + 'quantity' => Arr::get((array) $item, 'quantity', 1), + 'unit_price' => Arr::get((array) $item, 'price', 0), + 'amount' => Arr::get((array) $item, 'subtotal', 0), + 'currency' => 'USD', + 'details' => Arr::get((array) $item, 'name', 'Storefront item'), + 'description' => Arr::get((array) $item, 'name', 'Storefront item'), + 'code' => 'product', + 'sort_order' => $index, + 'meta' => $this->meta('transaction-item:' . $transaction->uuid . ':' . $index), + ]); + } + + if ($deliveryFee > 0) { + $this->createRecord(TransactionItem::class, [ + 'transaction_uuid' => $transaction->uuid, + 'quantity' => 1, + 'unit_price' => $deliveryFee, + 'amount' => $deliveryFee, + 'currency' => 'USD', + 'details' => 'Delivery fee', + 'description' => 'Delivery fee', + 'code' => 'delivery_fee', + 'sort_order' => 99, + 'meta' => $this->meta('transaction-item:' . $transaction->uuid . ':delivery'), + ]); + } + } +} diff --git a/server/seeders/Testing/Concerns/SeedsTestingData.php b/server/seeders/Testing/Concerns/SeedsTestingData.php new file mode 100644 index 0000000..906aef9 --- /dev/null +++ b/server/seeders/Testing/Concerns/SeedsTestingData.php @@ -0,0 +1,212 @@ +resolveSeedCompany(); + } + + protected function prepareCompany(): ?Company + { + $company = $this->resolveCompany(); + if (!$company) { + $this->command?->error('No company found. Create a Fleetbase company before running Storefront testing seeders.'); + + return null; + } + + session(['company' => $company->uuid]); + + $user = $this->resolveUser($company); + if ($user) { + session(['user' => $user->uuid]); + } + + return $company; + } + + protected function resolveUser(Company $company): ?User + { + if (Str::isUuid($company->owner_uuid)) { + $owner = User::where('uuid', $company->owner_uuid)->first(); + if ($owner) { + return $owner; + } + } + + $companyUser = CompanyUser::where('company_uuid', $company->uuid)->first(); + if ($companyUser) { + return User::where('uuid', $companyUser->user_uuid)->first(); + } + + return User::query()->orderBy('created_at')->first(); + } + + protected function fixtureKey(string $seedId): string + { + return static::SEED_NAME . ':' . $seedId; + } + + protected function meta(string $seedId, array $extra = []): array + { + return array_merge([ + 'seed' => static::SEED_NAME, + 'seed_id' => $seedId, + ], $extra); + } + + protected function timestamp(int $hoursOffset = 0): Carbon + { + return Carbon::parse('2026-01-15 08:00:00', 'Asia/Singapore')->addHours($hoursOffset); + } + + protected function createRecord(string $modelClass, array $attributes, bool $withoutEvents = false): Model + { + /** @var Model $model */ + $model = new $modelClass(); + $attributes = $this->filterColumns($model, array_merge([ + 'uuid' => (string) Str::uuid(), + 'created_at' => $this->timestamp(), + 'updated_at' => $this->timestamp(), + ], $attributes)); + + $model->forceFill($attributes); + + if ($withoutEvents) { + $modelClass::withoutEvents(fn () => $model->save()); + } else { + $model->save(); + } + + return $model; + } + + protected function filterColumns(Model $model, array $attributes): array + { + $table = $model->getTable(); + $connection = $model->getConnectionName(); + + if (!Schema::connection($connection)->hasTable($table)) { + return $attributes; + } + + return array_filter( + $attributes, + fn (string $column) => Schema::connection($connection)->hasColumn($table, $column), + ARRAY_FILTER_USE_KEY + ); + } + + protected function seededQuery(string $modelClass) + { + /** @var FleetbaseModel|Model $model */ + $model = new $modelClass(); + $table = $model->getTable(); + $connection = $model->getConnectionName(); + $query = $modelClass::query(); + + if (Schema::connection($connection)->hasColumn($table, 'meta')) { + return $query->where('meta->seed', static::SEED_NAME); + } + + if (Schema::connection($connection)->hasColumn($table, 'options')) { + return $query->where('options->seed', static::SEED_NAME); + } + + if (Schema::connection($connection)->hasColumn($table, '_key')) { + return $query->where('_key', 'like', static::SEED_NAME . ':%'); + } + + if (Schema::connection($connection)->hasColumn($table, 'unique_identifier')) { + return $query->where('unique_identifier', 'like', static::SEED_NAME . ':%'); + } + + return $query->whereRaw('1 = 0'); + } + + protected function seededUuids(string $modelClass): array + { + return $this->seededQuery($modelClass)->pluck('uuid')->filter()->values()->all(); + } + + protected function seededModel(string $modelClass, string $seedId): ?Model + { + /** @var Model $model */ + $model = new $modelClass(); + $table = $model->getTable(); + $connection = $model->getConnectionName(); + + if (Schema::connection($connection)->hasColumn($table, 'meta')) { + return $modelClass::where('meta->seed', static::SEED_NAME)->where('meta->seed_id', $seedId)->first(); + } + + if (Schema::connection($connection)->hasColumn($table, 'options')) { + return $modelClass::where('options->seed', static::SEED_NAME)->where('options->seed_id', $seedId)->first(); + } + + if (Schema::connection($connection)->hasColumn($table, '_key')) { + return $modelClass::where('_key', $this->fixtureKey($seedId))->first(); + } + + if (Schema::connection($connection)->hasColumn($table, 'unique_identifier')) { + return $modelClass::where('unique_identifier', $this->fixtureKey($seedId))->first(); + } + + return null; + } + + protected function purgeModel(string $modelClass): void + { + /** @var Model $model */ + $model = new $modelClass(); + $query = $this->seededQuery($modelClass); + + if (in_array(SoftDeletes::class, class_uses_recursive($modelClass), true) || method_exists($model, 'bootSoftDeletes')) { + $query->forceDelete(); + + return; + } + + $query->delete(); + } + + protected function deleteFrom(string $connection, string $table, callable $callback): void + { + if (!Schema::connection($connection)->hasTable($table)) { + return; + } + + $query = DB::connection($connection)->table($table); + $callback($query); + $query->delete(); + } + + protected function storefrontConnection(): string + { + return config('storefront.connection.db'); + } + + protected function fleetbaseConnection(): string + { + return config('fleetbase.connection.db'); + } +} diff --git a/server/seeders/Testing/TestingSeeder.php b/server/seeders/Testing/TestingSeeder.php new file mode 100644 index 0000000..2aeb115 --- /dev/null +++ b/server/seeders/Testing/TestingSeeder.php @@ -0,0 +1,36 @@ +disableForeignKeyConstraints(); + Schema::connection(config('storefront.connection.db'))->disableForeignKeyConstraints(); + try { + $this->purgeSeedData(); + } finally { + Schema::connection(config('storefront.connection.db'))->enableForeignKeyConstraints(); + Schema::connection(config('fleetbase.connection.db'))->enableForeignKeyConstraints(); + } + + $this->call([ + CatalogAndProductsSeeder::class, + CheckoutOrdersSeeder::class, + ]); + } + + protected function purgeSeedData(): void + { + foreach ([ + new CheckoutOrdersSeeder(), + new CatalogAndProductsSeeder(), + ] as $seeder) { + $seeder->purgeSeedData(); + } + } +} diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index c4d963c..1c51f5c 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -4,8 +4,10 @@ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController as FleetbaseOrderController; use Fleetbase\FleetOps\Models\Order; +use Fleetbase\Storefront\Http\Resources\Index\Order as StorefrontOrderIndexResource; use Fleetbase\Storefront\Notifications\StorefrontOrderAccepted; use Fleetbase\Storefront\Support\Storefront; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -18,6 +20,13 @@ class OrderController extends FleetbaseOrderController */ public $resource = 'order'; + /** + * Storefront order lists need checkout totals and customer context. + * + * @var string + */ + public $indexResource = StorefrontOrderIndexResource::class; + /** * The filter to use. * @@ -25,6 +34,11 @@ class OrderController extends FleetbaseOrderController */ public $filter = \Fleetbase\Storefront\Http\Filter\OrderFilter::class; + public function onQueryRecord(Builder $query): void + { + $query->with(['customer', 'transaction', 'payload']); + } + /** * Accept an order by incrementing status to preparing. * diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php new file mode 100644 index 0000000..b77c0fc --- /dev/null +++ b/server/src/Http/Resources/Index/Order.php @@ -0,0 +1,25 @@ +customer_name; + $data['transaction_amount'] = $this->transaction_amount; + $data['meta'] = data_get($this, 'meta', Utils::createObject()); + + return $data; + } +} From 0b8026d10b0e96027d3be02be9471f74998376e8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 29 May 2026 18:14:39 +0800 Subject: [PATCH 09/14] Fix storefront orders and dashboard widgets --- addon/components/widget/customers.hbs | 87 ++++----- addon/extension.js | 48 ++--- addon/routes/networks/index/network/orders.js | 21 ++- addon/routes/orders/index.js | 21 ++- addon/styles/storefront-engine.css | 174 +++++++++++++++--- .../Testing/Concerns/SeedsTestingData.php | 14 +- .../src/Http/Controllers/OrderController.php | 10 +- server/src/Http/Resources/Index/Order.php | 43 ++++- server/src/Http/Resources/v1/Index/Order.php | 57 +----- server/tests/Feature.php | 45 ++++- .../components/widget/customers-test.js | 63 +++++-- .../networks/index/network/orders-test.js | 61 +++++- tests/unit/routes/orders/index-test.js | 61 +++++- 13 files changed, 525 insertions(+), 180 deletions(-) diff --git a/addon/components/widget/customers.hbs b/addon/components/widget/customers.hbs index b508d27..ac03d6f 100644 --- a/addon/components/widget/customers.hbs +++ b/addon/components/widget/customers.hbs @@ -1,66 +1,69 @@ - -
-
+ {{#if (media "isMobile")}} -
+
{{#each this.customers as |customer|}} -
-
-
- {{customer.name}} -
-
-
{{customer.name}}
-
{{customer.email}}
-
{{customer.phone}}
+
+
+ {{n-a customer.phone}} + {{n-a customer.email}} +
+ {{else}}
No recent customers yet.
{{/each}}
{{else}} -
- +
+
- - - - - - - + + + + + + {{#each this.customers as |customer|}} - - - - - - - + + + + + + {{else}} - + {{/each}}
{{t "storefront.common.id"}}{{t "storefront.common.name"}}{{t "storefront.common.phone"}}{{t "storefront.common.email"}}{{t "storefront.common.orders"}}
{{t "storefront.common.id"}}{{t "storefront.common.name"}}{{t "storefront.common.phone"}}{{t "storefront.common.email"}}{{t "storefront.common.orders"}}
{{customer.public_id}}{{n-a customer.name}}{{n-a customer.phone}}{{n-a customer.email}}{{n-a customer.orders 0}}
{{customer.public_id}} +
+ {{customer.name}} + {{n-a customer.name}} +
+
{{n-a customer.phone}}{{n-a customer.email}}{{n-a customer.orders 0}}
No recent customers yet.
No recent customers yet.
{{/if}} - +
diff --git a/addon/extension.js b/addon/extension.js index f3228f0..ecf7173 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -108,26 +108,27 @@ export function registerWidgets(widgetService) { category: 'Analytics', default: true, }), - new Widget({ - id: 'storefront-orders-by-status-widget', - name: 'Order Status Mix', - description: 'Distribution of Storefront orders by status.', - icon: 'chart-column', - component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders-by-status'), - grid_options: { w: 6, h: 9, minW: 5, minH: 8 }, - category: 'Analytics', - default: true, - }), new Widget({ id: 'storefront-top-products-widget', name: 'Top Products', description: 'Best-selling products by revenue.', icon: 'ranking-star', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/top-products'), - grid_options: { w: 6, h: 9, minW: 5, minH: 8 }, + grid_options: { w: 6, h: 10, minW: 5, minH: 9 }, category: 'Analytics', default: true, }), + new Widget({ + id: 'storefront-orders-widget', + name: 'Storefront Orders', + description: 'Recent Storefront orders.', + icon: 'bag-shopping', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders'), + grid_options: { w: 12, h: 11, minW: 8, minH: 8 }, + options: { wrapperClass: 'bordered-classic' }, + category: 'Operations', + default: true, + }), new Widget({ id: 'storefront-customer-insights-widget', name: 'Customer Insights', @@ -138,6 +139,16 @@ export function registerWidgets(widgetService) { category: 'Analytics', default: true, }), + new Widget({ + id: 'storefront-orders-by-status-widget', + name: 'Order Status Mix', + description: 'Distribution of Storefront orders by status.', + icon: 'chart-column', + component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders-by-status'), + grid_options: { w: 6, h: 9, minW: 5, minH: 8 }, + category: 'Analytics', + default: true, + }), new Widget({ id: 'storefront-metrics-widget', name: 'Storefront Metrics (Legacy)', @@ -148,27 +159,16 @@ export function registerWidgets(widgetService) { category: 'Legacy', default: false, }), - new Widget({ - id: 'storefront-orders-widget', - name: 'Storefront Orders', - description: 'Recent Storefront orders.', - icon: 'bag-shopping', - component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/orders'), - grid_options: { w: 12, h: 10, minW: 8, minH: 8 }, - options: { wrapperClass: 'bordered-classic' }, - category: 'Operations', - default: true, - }), new Widget({ id: 'storefront-customers-widget', name: 'Storefront Customers', description: 'Recent Storefront customers.', icon: 'users', component: new ExtensionComponent('@fleetbase/storefront-engine', 'widget/customers'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 12, h: 11, minW: 8, minH: 8 }, options: { wrapperClass: 'bordered-classic' }, category: 'Operations', - default: false, + default: true, }), ]); diff --git a/addon/routes/networks/index/network/orders.js b/addon/routes/networks/index/network/orders.js index 33943fe..36e2adb 100644 --- a/addon/routes/networks/index/network/orders.js +++ b/addon/routes/networks/index/network/orders.js @@ -2,10 +2,25 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; export default class NetworksIndexNetworkOrdersRoute extends Route { - @service store; @service storefront; + @service fetch; - model(params) { - return this.store.query('order', { ...params, storefront: this.storefront.getActiveStore('public_id') }); + async model(params) { + const response = await this.fetch.get('orders', this.buildQueryParams(params), { namespace: 'storefront/int/v1' }); + const orders = this.fetch.normalizeModel(response, 'orders'); + + orders.meta = response.meta; + + return orders; + } + + buildQueryParams(params = {}) { + return Object.entries({ ...params, storefront: this.storefront.getActiveStore('public_id') }).reduce((queryParams, [key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams[key] = value; + } + + return queryParams; + }, {}); } } diff --git a/addon/routes/orders/index.js b/addon/routes/orders/index.js index 5cea1e3..db96054 100644 --- a/addon/routes/orders/index.js +++ b/addon/routes/orders/index.js @@ -2,8 +2,8 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; export default class OrdersIndexRoute extends Route { - @service store; @service storefront; + @service fetch; @service intl; @service abilities; @service hostRouter; @@ -35,7 +35,22 @@ export default class OrdersIndexRoute extends Route { } } - model(params) { - return this.store.query('order', { ...params, storefront: this.storefront.getActiveStore('public_id') }); + async model(params) { + const response = await this.fetch.get('orders', this.buildQueryParams(params), { namespace: 'storefront/int/v1' }); + const orders = this.fetch.normalizeModel(response, 'orders'); + + orders.meta = response.meta; + + return orders; + } + + buildQueryParams(params = {}) { + return Object.entries({ ...params, storefront: this.storefront.getActiveStore('public_id') }).reduce((queryParams, [key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams[key] = value; + } + + return queryParams; + }, {}); } } diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index aacbe7e..99efc94 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -549,7 +549,8 @@ body[data-theme='dark'] .storefront-table-toolbar { overflow: hidden; } -.storefront-orders-header { +.storefront-orders-header, +.storefront-customers-header { display: flex; min-height: 3.65rem; align-items: center; @@ -559,32 +560,44 @@ body[data-theme='dark'] .storefront-table-toolbar { padding: 0.75rem 1rem; } -body[data-theme='dark'] .storefront-orders-header { +body[data-theme='dark'] .storefront-orders-header, +body[data-theme='dark'] .storefront-customers-header { border-bottom-color: #374151; } -.storefront-orders-actions { +.storefront-orders-actions, +.storefront-customers-actions { display: flex; flex-shrink: 0; align-items: center; gap: 0.6rem; } -.storefront-orders-table-wrap { +.storefront-orders-table-wrap, +.storefront-customers-table-wrap { min-height: 0; flex: 1 1 auto; overflow: auto; } -.storefront-orders-table { +.storefront-orders-table, +.storefront-customers-table { width: 100%; - min-width: 880px; border-collapse: separate; border-spacing: 0; table-layout: fixed; } -.storefront-orders-table th { +.storefront-orders-table { + min-width: 880px; +} + +.storefront-customers-table { + min-width: 760px; +} + +.storefront-orders-table th, +.storefront-customers-table th { position: sticky; top: 0; z-index: 1; @@ -600,30 +613,31 @@ body[data-theme='dark'] .storefront-orders-header { text-transform: uppercase; } -body[data-theme='dark'] .storefront-orders-table th { +body[data-theme='dark'] .storefront-orders-table th, +body[data-theme='dark'] .storefront-customers-table th { border-bottom-color: #374151; color: #9ca3af; background-color: #111827; } .storefront-orders-table th:nth-child(1) { - width: 14%; + width: 17%; } .storefront-orders-table th:nth-child(2) { - width: 12%; + width: 9%; } .storefront-orders-table th:nth-child(3) { - width: 16%; + width: 17%; } .storefront-orders-table th:nth-child(4) { - width: 16%; + width: 15%; } .storefront-orders-table th:nth-child(5) { - width: 12%; + width: 9%; } .storefront-orders-table th:nth-child(6) { @@ -631,10 +645,32 @@ body[data-theme='dark'] .storefront-orders-table th { } .storefront-orders-table th:nth-child(7) { + width: 19%; +} + +.storefront-customers-table th:nth-child(1) { + width: 20%; +} + +.storefront-customers-table th:nth-child(2) { + width: 23%; +} + +.storefront-customers-table th:nth-child(3) { width: 16%; } -.storefront-orders-table td { +.storefront-customers-table th:nth-child(4) { + width: 31%; +} + +.storefront-customers-table th:nth-child(5) { + width: 10%; + text-align: right; +} + +.storefront-orders-table td, +.storefront-customers-table td { border-bottom: 1px solid #eef2f7; color: #111827; font-size: 0.76rem; @@ -643,16 +679,19 @@ body[data-theme='dark'] .storefront-orders-table th { vertical-align: middle; } -body[data-theme='dark'] .storefront-orders-table td { +body[data-theme='dark'] .storefront-orders-table td, +body[data-theme='dark'] .storefront-customers-table td { border-bottom-color: #2d3748; color: #f9fafb; } -.storefront-orders-table tbody tr:hover td { +.storefront-orders-table tbody tr:hover td, +.storefront-customers-table tbody tr:hover td { background-color: #f8fafc; } -body[data-theme='dark'] .storefront-orders-table tbody tr:hover td { +body[data-theme='dark'] .storefront-orders-table tbody tr:hover td, +body[data-theme='dark'] .storefront-customers-table tbody tr:hover td { background-color: #172033; } @@ -665,6 +704,15 @@ body[data-theme='dark'] .storefront-order-id { color: #93c5fd; } +.storefront-customer-id { + color: #2563eb; + font-weight: 800; +} + +body[data-theme='dark'] .storefront-customer-id { + color: #93c5fd; +} + .storefront-order-total { color: #047857; font-weight: 800; @@ -697,48 +745,126 @@ body[data-theme='dark'] .storefront-table-muted { gap: 0.35rem; } -.storefront-orders-mobile-list { +.storefront-widget-count { + display: inline-flex; + min-width: 2.15rem; + height: 1.35rem; + align-items: center; + justify-content: center; + border-radius: 0.3rem; + color: #1d4ed8; + background-color: #dbeafe; + font-size: 0.74rem; + font-weight: 800; +} + +body[data-theme='dark'] .storefront-widget-count { + color: #bfdbfe; + background-color: #1d4ed8; +} + +.storefront-customer-cell { + display: flex; + min-width: 0; + align-items: center; + gap: 0.65rem; +} + +.storefront-customer-avatar { + width: 2rem; + height: 2rem; + flex: 0 0 auto; + border-radius: 0.35rem; + object-fit: cover; + box-shadow: 0 1px 2px rgb(15 23 42 / 16%); +} + +.storefront-customer-name { + display: block; + overflow: hidden; + color: #111827; + font-size: 0.82rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-customer-name { + color: #f9fafb; +} + +.storefront-customer-order-count { + display: inline-flex; + min-width: 2rem; + justify-content: flex-end; + color: #475569; + font-weight: 800; +} + +body[data-theme='dark'] .storefront-customer-order-count { + color: #cbd5e1; +} + +.storefront-customers-table td:last-child { + text-align: right; +} + +.storefront-orders-mobile-list, +.storefront-customers-mobile-list { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 0.75rem; } -.storefront-order-card { +.storefront-order-card, +.storefront-customer-card { border: 1px solid #e5e7eb; border-radius: 0.375rem; padding: 0.85rem; background-color: #fff; } -.storefront-order-card + .storefront-order-card { +.storefront-order-card + .storefront-order-card, +.storefront-customer-card + .storefront-customer-card { margin-top: 0.75rem; } -body[data-theme='dark'] .storefront-order-card { +body[data-theme='dark'] .storefront-order-card, +body[data-theme='dark'] .storefront-customer-card { border-color: #374151; background-color: #111827; } .storefront-order-meta, -.storefront-order-card-details { +.storefront-order-card-details, +.storefront-customer-card-details { color: #64748b; font-size: 0.72rem; font-weight: 600; } body[data-theme='dark'] .storefront-order-meta, -body[data-theme='dark'] .storefront-order-card-details { +body[data-theme='dark'] .storefront-order-card-details, +body[data-theme='dark'] .storefront-customer-card-details { color: #9ca3af; } -.storefront-order-card-details { +.storefront-order-card-details, +.storefront-customer-card-details { display: flex; justify-content: space-between; gap: 0.75rem; margin-top: 0.75rem; } +.storefront-customer-card-details span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .storefront-order-card-actions { display: flex; flex-wrap: wrap; diff --git a/server/seeders/Testing/Concerns/SeedsTestingData.php b/server/seeders/Testing/Concerns/SeedsTestingData.php index 906aef9..2e63211 100644 --- a/server/seeders/Testing/Concerns/SeedsTestingData.php +++ b/server/seeders/Testing/Concerns/SeedsTestingData.php @@ -76,7 +76,19 @@ protected function meta(string $seedId, array $extra = []): array protected function timestamp(int $hoursOffset = 0): Carbon { - return Carbon::parse('2026-01-15 08:00:00', 'Asia/Singapore')->addHours($hoursOffset); + $now = Carbon::now($this->seedTimezone()); + $timestamp = $now->copy()->startOfMonth()->addHours(8 + $hoursOffset); + + if ($timestamp->greaterThan($now)) { + return $now; + } + + return $timestamp; + } + + protected function seedTimezone(): string + { + return config('app.timezone') ?: 'UTC'; } protected function createRecord(string $modelClass, array $attributes, bool $withoutEvents = false): Model diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index 2eb36fc..2551969 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -4,11 +4,7 @@ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController as FleetbaseOrderController; use Fleetbase\FleetOps\Models\Order; -<<<<<<< feature/issue-71-replace-storefront-static-home-dashboard-with-registered-das-26507610461 use Fleetbase\Storefront\Http\Resources\Index\Order as StorefrontOrderIndexResource; -======= -use Fleetbase\Storefront\Http\Resources\v1\Index\Order as StorefrontOrderIndexResource; ->>>>>>> dev-v0.4.15 use Fleetbase\Storefront\Notifications\StorefrontOrderAccepted; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Database\Eloquent\Builder; @@ -25,11 +21,7 @@ class OrderController extends FleetbaseOrderController public $resource = 'order'; /** -<<<<<<< feature/issue-71-replace-storefront-static-home-dashboard-with-registered-das-26507610461 * Storefront order lists need checkout totals and customer context. -======= - * The resource to use for index queries. ->>>>>>> dev-v0.4.15 * * @var string */ @@ -44,7 +36,7 @@ class OrderController extends FleetbaseOrderController public function onQueryRecord(Builder $query): void { - $query->with(['customer', 'transaction', 'payload']); + $query->with(['customer', 'transaction', 'payload', 'driverAssigned', 'trackingNumber', 'trackingStatuses']); } /** diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php index b77c0fc..f2d3485 100644 --- a/server/src/Http/Resources/Index/Order.php +++ b/server/src/Http/Resources/Index/Order.php @@ -3,7 +3,7 @@ namespace Fleetbase\Storefront\Http\Resources\Index; use Fleetbase\FleetOps\Http\Resources\v1\Index\Order as FleetOpsOrderIndexResource; -use Fleetbase\FleetOps\Support\Utils; +use Illuminate\Contracts\Support\Arrayable; class Order extends FleetOpsOrderIndexResource { @@ -18,8 +18,47 @@ public function toArray($request): array $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; - $data['meta'] = data_get($this, 'meta', Utils::createObject()); + $data['meta'] = array_replace( + $this->normalizeMeta(data_get($data, 'meta', [])), + $this->storefrontOrderMeta() + ); return $data; } + + private function storefrontOrderMeta(): array + { + $keys = [ + 'storefront', + 'storefront_id', + 'storefront_network', + 'storefront_network_id', + 'subtotal', + 'delivery_fee', + 'tip', + 'delivery_tip', + 'total', + 'currency', + 'gateway', + 'is_pickup', + 'is_master_order', + 'related_orders', + 'master_order_id', + ]; + + return array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys)); + } + + private function normalizeMeta($meta): array + { + if ($meta instanceof Arrayable) { + $meta = $meta->toArray(); + } + + if (is_object($meta)) { + $meta = (array) $meta; + } + + return is_array($meta) ? $meta : []; + } } diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php index 4fd70fb..d697b87 100644 --- a/server/src/Http/Resources/v1/Index/Order.php +++ b/server/src/Http/Resources/v1/Index/Order.php @@ -2,61 +2,8 @@ namespace Fleetbase\Storefront\Http\Resources\v1\Index; -use Fleetbase\FleetOps\Http\Resources\v1\Index\Order as FleetOpsOrderIndexResource; -use Illuminate\Contracts\Support\Arrayable; +use Fleetbase\Storefront\Http\Resources\Index\Order as StorefrontOrderIndexResource; -class Order extends FleetOpsOrderIndexResource +class Order extends StorefrontOrderIndexResource { - /** - * Transform the resource into an array. - * - * @param \Illuminate\Http\Request $request - */ - public function toArray($request): array - { - $order = parent::toArray($request); - - $order['meta'] = array_replace( - $this->normalizeMeta(data_get($order, 'meta', [])), - $this->storefrontOrderMeta() - ); - - return $order; - } - - private function storefrontOrderMeta(): array - { - $keys = [ - 'storefront', - 'storefront_id', - 'storefront_network', - 'storefront_network_id', - 'subtotal', - 'delivery_fee', - 'tip', - 'delivery_tip', - 'total', - 'currency', - 'gateway', - 'is_pickup', - 'is_master_order', - 'related_orders', - 'master_order_id', - ]; - - return array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys)); - } - - private function normalizeMeta($meta): array - { - if ($meta instanceof Arrayable) { - $meta = $meta->toArray(); - } - - if (is_object($meta)) { - $meta = (array) $meta; - } - - return is_array($meta) ? $meta : []; - } } diff --git a/server/tests/Feature.php b/server/tests/Feature.php index 61cd84c..d3c1b05 100644 --- a/server/tests/Feature.php +++ b/server/tests/Feature.php @@ -1,5 +1,46 @@ toBeTrue(); +use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Models\Order as FleetOpsOrder; +use Fleetbase\Models\Transaction; +use Fleetbase\Storefront\Http\Resources\Index\Order as StorefrontOrderIndexResource; + +test('storefront order index resource includes checkout meta and storefront display fields', function () { + $order = new FleetOpsOrder(); + $order->forceFill([ + 'public_id' => 'order_123', + 'internal_id' => '1001', + 'status' => 'created', + 'meta' => [ + 'subtotal' => 42.25, + 'delivery_fee' => 7.75, + 'total' => 50, + 'currency' => 'USD', + 'is_pickup' => false, + 'unrelated' => 'hidden', + ], + ]); + $customer = new Contact(); + $customer->forceFill(['name' => 'Ada Lovelace']); + + $transaction = new Transaction(); + $transaction->forceFill(['amount' => 50]); + + $order->setRelation('customer', $customer); + $order->setRelation('transaction', $transaction); + + $data = (new StorefrontOrderIndexResource($order))->toArray(request()); + + expect($data)->toBeArray() + ->and($data['customer_name'])->toBe('Ada Lovelace') + ->and($data['transaction_amount'])->toBe($order->transaction_amount) + ->and($data['meta'])->toMatchArray([ + '_index_resource' => true, + 'subtotal' => 42.25, + 'delivery_fee' => 7.75, + 'total' => 50, + 'currency' => 'USD', + 'is_pickup' => false, + ]) + ->and($data['meta'])->not->toHaveKey('unrelated'); }); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index ca1e75e..9d51d23 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -1,26 +1,63 @@ +import Service from '@ember/service'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +class StorefrontStubService extends Service { + activeStore = { public_id: 'store_1' }; + on() {} +} + +class FetchStubService extends Service { + get() { + return { + customers: [ + { + public_id: 'contact_1', + name: 'Ava Chen', + phone: '+15550201', + email: 'ava.chen@example.test', + orders: 2, + }, + ], + }; + } +} + +class IntlStubService extends Service { + t(key) { + if (key === 'storefront.component.widget.customers.widget-title') { + return 'Recent Customers'; + } + + return key; + } +} + +class ContextPanelStubService extends Service { + focus() {} +} + module('Integration | Component | widget/customers', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + this.owner.register('service:fetch', FetchStubService); + this.owner.register('service:intl', IntlStubService); + this.owner.register('service:context-panel', ContextPanelStubService); + }); + test('it renders the polished customers table shell', async function (assert) { await render(hbs``); - assert.dom(this.element).hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom(this.element).hasText('template block text'); + assert.dom('.storefront-customers-widget').exists(); + assert.dom('.storefront-customers-header').includesText('Recent Customers'); + assert.dom('.storefront-widget-count').hasText('1'); + assert.dom('.storefront-customers-table').exists(); + assert.dom('.storefront-customer-id').hasText('contact_1'); + assert.dom('.storefront-customers-table').includesText('Ava Chen'); + assert.dom('.storefront-customers-table').includesText('ava.chen@example.test'); }); }); diff --git a/tests/unit/routes/networks/index/network/orders-test.js b/tests/unit/routes/networks/index/network/orders-test.js index ba5b6c6..d958a2b 100644 --- a/tests/unit/routes/networks/index/network/orders-test.js +++ b/tests/unit/routes/networks/index/network/orders-test.js @@ -1,11 +1,70 @@ +import Service, { inject as service } from '@ember/service'; import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +class StorefrontStubService extends Service { + getActiveStore(key) { + if (key === 'public_id') { + return 'store_123'; + } + } +} + +class FetchStubService extends Service { + @service store; + + calls = []; + + async get(path, params, options) { + this.calls.push({ path, params, options }); + + return { + orders: [ + { + id: 'order_456', + public_id: 'order_456', + meta: { + total: 19.99, + currency: 'USD', + }, + }, + ], + meta: { + current_page: 1, + total: 1, + }, + }; + } + + normalizeModel(payload, modelType) { + this.normalized = { payload, modelType }; + + return payload.orders.map((order) => this.store.createRecord('order', order)); + } +} + module('Unit | Route | networks/index/network/orders', function (hooks) { setupTest(hooks); - test('it exists', function (assert) { + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + this.owner.register('service:fetch', FetchStubService); + }); + + test('it loads orders from the Storefront internal namespace as Ember Data models', async function (assert) { let route = this.owner.lookup('route:networks/index/network/orders'); + let fetch = this.owner.lookup('service:fetch'); + assert.ok(route); + + let orders = await route.model({ page: 2, limit: undefined, sort: '-created_at', query: undefined, status: '', customer: null }); + let request = fetch.calls[0]; + + assert.strictEqual(request.path, 'orders'); + assert.deepEqual(request.params, { page: 2, sort: '-created_at', storefront: 'store_123' }); + assert.deepEqual(request.options, { namespace: 'storefront/int/v1' }); + assert.strictEqual(fetch.normalized.modelType, 'orders'); + assert.strictEqual(orders[0].constructor.modelName, 'order'); + assert.deepEqual(orders.meta, { current_page: 1, total: 1 }); }); }); diff --git a/tests/unit/routes/orders/index-test.js b/tests/unit/routes/orders/index-test.js index 4109d01..3cf8e56 100644 --- a/tests/unit/routes/orders/index-test.js +++ b/tests/unit/routes/orders/index-test.js @@ -1,11 +1,70 @@ +import Service, { inject as service } from '@ember/service'; import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +class StorefrontStubService extends Service { + getActiveStore(key) { + if (key === 'public_id') { + return 'store_123'; + } + } +} + +class FetchStubService extends Service { + @service store; + + calls = []; + + async get(path, params, options) { + this.calls.push({ path, params, options }); + + return { + orders: [ + { + id: 'order_123', + public_id: 'order_123', + meta: { + total: 52.45, + currency: 'USD', + }, + }, + ], + meta: { + current_page: 1, + total: 1, + }, + }; + } + + normalizeModel(payload, modelType) { + this.normalized = { payload, modelType }; + + return payload.orders.map((order) => this.store.createRecord('order', order)); + } +} + module('Unit | Route | orders/index', function (hooks) { setupTest(hooks); - test('it exists', function (assert) { + hooks.beforeEach(function () { + this.owner.register('service:storefront', StorefrontStubService); + this.owner.register('service:fetch', FetchStubService); + }); + + test('it loads orders from the Storefront internal namespace as Ember Data models', async function (assert) { let route = this.owner.lookup('route:orders/index'); + let fetch = this.owner.lookup('service:fetch'); + assert.ok(route); + + let orders = await route.model({ page: 1, limit: undefined, sort: '-created_at', query: undefined, status: '', customer: null }); + let request = fetch.calls[0]; + + assert.strictEqual(request.path, 'orders'); + assert.deepEqual(request.params, { page: 1, sort: '-created_at', storefront: 'store_123' }); + assert.deepEqual(request.options, { namespace: 'storefront/int/v1' }); + assert.strictEqual(fetch.normalized.modelType, 'orders'); + assert.strictEqual(orders[0].constructor.modelName, 'order'); + assert.deepEqual(orders.meta, { current_page: 1, total: 1 }); }); }); From 42444bc3e8ae15ee9a2dd9abf1b72621e1438313 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 31 May 2026 11:07:45 +0800 Subject: [PATCH 10/14] Completed refactor of dashboard + order details --- addon/components/customer-panel/orders.js | 19 +- addon/components/order-panel.hbs | 314 ----- addon/components/order-panel.js | 84 -- addon/components/order-panel/details.hbs | 245 ---- addon/components/order-panel/details.js | 187 --- .../storefront/order/activity-list.hbs | 34 + .../storefront/order/activity-list.js | 3 + .../storefront/order/activity-timeline.hbs | 35 + .../storefront/order/activity-timeline.js | 3 + addon/components/storefront/order/details.hbs | 29 + addon/components/storefront/order/details.js | 3 + .../storefront/order/details/activity.hbs | 14 + .../storefront/order/details/activity.js | 69 ++ .../storefront/order/details/comments.hbs | 10 + .../storefront/order/details/comments.js | 3 + .../order/details/commerce-summary.hbs | 71 ++ .../order/details/commerce-summary.js | 3 + .../order/details/customer-insights.hbs | 24 + .../order/details/customer-insights.js | 39 + .../storefront/order/details/customer.hbs | 24 + .../storefront/order/details/customer.js | 3 + .../storefront/order/details/detail.hbs | 69 ++ .../storefront/order/details/detail.js | 31 + .../storefront/order/details/documents.hbs | 76 ++ .../storefront/order/details/documents.js | 66 + .../storefront/order/details/fulfillment.hbs | 57 + .../storefront/order/details/fulfillment.js | 3 + .../storefront/order/details/metadata.hbs | 3 + .../storefront/order/details/metadata.js | 8 + .../order/details/order-breakdown.hbs | 1 + .../order/details/order-breakdown.js | 3 + .../order/details/registered-tab.hbs | 3 + .../order/details/registered-tab.js | 10 + .../storefront/order/details/route.hbs | 47 + .../storefront/order/details/route.js | 69 ++ .../storefront/order/details/store.hbs | 51 + .../storefront/order/details/store.js | 96 ++ .../storefront/order/details/tracking.hbs | 206 +++ .../storefront/order/details/tracking.js | 609 +++++++++ .../storefront/order/panel-header.hbs | 41 + .../storefront/order/panel-header.js | 3 + addon/components/tracking-stop-progress.hbs | 45 + addon/components/tracking-stop-progress.js | 79 ++ addon/components/widget/orders.js | 21 +- .../networks/index/network/orders.js | 4 +- addon/controllers/orders/index.js | 2 +- addon/controllers/orders/index/view.js | 26 + addon/controllers/orders/index/view/index.js | 5 + addon/routes.js | 5 +- addon/routes/orders/index/view.js | 21 +- addon/routes/orders/index/view/virtual.js | 24 + addon/services/context-panel.js | 6 - addon/services/order-actions.js | 280 +---- addon/services/storefront-order-actions.js | 378 ++++++ addon/styles/storefront-engine.css | 1100 ++++++++++++++++- addon/templates/application.hbs | 3 +- addon/templates/orders/index/view.hbs | 16 +- addon/templates/orders/index/view/index.hbs | 2 + addon/templates/orders/index/view/virtual.hbs | 2 + .../storefront/order/activity-list.js | 1 + .../storefront/order/activity-timeline.js | 1 + .../order}/details.js | 2 +- .../storefront/order/details/activity.js | 1 + .../storefront/order/details/comments.js | 1 + .../order/details/commerce-summary.js | 1 + .../order/details/customer-insights.js | 1 + .../storefront/order/details/customer.js | 1 + .../storefront/order/details/detail.js | 1 + .../storefront/order/details/documents.js | 1 + .../storefront/order/details/fulfillment.js | 1 + .../storefront/order/details/metadata.js | 1 + .../order/details/order-breakdown.js | 1 + .../order/details/registered-tab.js | 1 + .../storefront/order/details/route.js | 1 + .../storefront/order/details/store.js | 1 + .../storefront/order/details/tracking.js | 1 + .../storefront/order/panel-header.js | 1 + ...der-panel.js => tracking-stop-progress.js} | 2 +- app/controllers/orders/index/view/index.js | 1 + app/routes/orders/index/view/virtual.js | 1 + app/services/storefront-order-actions.js | 1 + app/templates/orders/index/view/index.js | 1 + app/templates/orders/index/view/virtual.js | 1 + .../Http/Controllers/AnalyticsController.php | 32 +- .../src/Http/Controllers/OrderController.php | 38 + server/src/Http/Resources/Order.php | 80 ++ server/tests/Feature.php | 92 ++ .../components/order-panel-test.js | 26 - .../components/order-panel/details-test.js | 26 - .../storefront/order/details-test.js | 66 + tests/unit/routes/orders/index/view-test.js | 55 + tests/unit/services/order-actions-test.js | 7 + .../services/storefront-order-actions-test.js | 65 + 93 files changed, 3998 insertions(+), 1202 deletions(-) delete mode 100644 addon/components/order-panel.hbs delete mode 100644 addon/components/order-panel.js delete mode 100644 addon/components/order-panel/details.hbs delete mode 100644 addon/components/order-panel/details.js create mode 100644 addon/components/storefront/order/activity-list.hbs create mode 100644 addon/components/storefront/order/activity-list.js create mode 100644 addon/components/storefront/order/activity-timeline.hbs create mode 100644 addon/components/storefront/order/activity-timeline.js create mode 100644 addon/components/storefront/order/details.hbs create mode 100644 addon/components/storefront/order/details.js create mode 100644 addon/components/storefront/order/details/activity.hbs create mode 100644 addon/components/storefront/order/details/activity.js create mode 100644 addon/components/storefront/order/details/comments.hbs create mode 100644 addon/components/storefront/order/details/comments.js create mode 100644 addon/components/storefront/order/details/commerce-summary.hbs create mode 100644 addon/components/storefront/order/details/commerce-summary.js create mode 100644 addon/components/storefront/order/details/customer-insights.hbs create mode 100644 addon/components/storefront/order/details/customer-insights.js create mode 100644 addon/components/storefront/order/details/customer.hbs create mode 100644 addon/components/storefront/order/details/customer.js create mode 100644 addon/components/storefront/order/details/detail.hbs create mode 100644 addon/components/storefront/order/details/detail.js create mode 100644 addon/components/storefront/order/details/documents.hbs create mode 100644 addon/components/storefront/order/details/documents.js create mode 100644 addon/components/storefront/order/details/fulfillment.hbs create mode 100644 addon/components/storefront/order/details/fulfillment.js create mode 100644 addon/components/storefront/order/details/metadata.hbs create mode 100644 addon/components/storefront/order/details/metadata.js create mode 100644 addon/components/storefront/order/details/order-breakdown.hbs create mode 100644 addon/components/storefront/order/details/order-breakdown.js create mode 100644 addon/components/storefront/order/details/registered-tab.hbs create mode 100644 addon/components/storefront/order/details/registered-tab.js create mode 100644 addon/components/storefront/order/details/route.hbs create mode 100644 addon/components/storefront/order/details/route.js create mode 100644 addon/components/storefront/order/details/store.hbs create mode 100644 addon/components/storefront/order/details/store.js create mode 100644 addon/components/storefront/order/details/tracking.hbs create mode 100644 addon/components/storefront/order/details/tracking.js create mode 100644 addon/components/storefront/order/panel-header.hbs create mode 100644 addon/components/storefront/order/panel-header.js create mode 100644 addon/components/tracking-stop-progress.hbs create mode 100644 addon/components/tracking-stop-progress.js create mode 100644 addon/controllers/orders/index/view/index.js create mode 100644 addon/routes/orders/index/view/virtual.js create mode 100644 addon/services/storefront-order-actions.js create mode 100644 addon/templates/orders/index/view/index.hbs create mode 100644 addon/templates/orders/index/view/virtual.hbs create mode 100644 app/components/storefront/order/activity-list.js create mode 100644 app/components/storefront/order/activity-timeline.js rename app/components/{order-panel => storefront/order}/details.js (69%) create mode 100644 app/components/storefront/order/details/activity.js create mode 100644 app/components/storefront/order/details/comments.js create mode 100644 app/components/storefront/order/details/commerce-summary.js create mode 100644 app/components/storefront/order/details/customer-insights.js create mode 100644 app/components/storefront/order/details/customer.js create mode 100644 app/components/storefront/order/details/detail.js create mode 100644 app/components/storefront/order/details/documents.js create mode 100644 app/components/storefront/order/details/fulfillment.js create mode 100644 app/components/storefront/order/details/metadata.js create mode 100644 app/components/storefront/order/details/order-breakdown.js create mode 100644 app/components/storefront/order/details/registered-tab.js create mode 100644 app/components/storefront/order/details/route.js create mode 100644 app/components/storefront/order/details/store.js create mode 100644 app/components/storefront/order/details/tracking.js create mode 100644 app/components/storefront/order/panel-header.js rename app/components/{order-panel.js => tracking-stop-progress.js} (71%) create mode 100644 app/controllers/orders/index/view/index.js create mode 100644 app/routes/orders/index/view/virtual.js create mode 100644 app/services/storefront-order-actions.js create mode 100644 app/templates/orders/index/view/index.js create mode 100644 app/templates/orders/index/view/virtual.js create mode 100644 server/src/Http/Resources/Order.php delete mode 100644 tests/integration/components/order-panel-test.js delete mode 100644 tests/integration/components/order-panel/details-test.js create mode 100644 tests/integration/components/storefront/order/details-test.js create mode 100644 tests/unit/services/storefront-order-actions-test.js diff --git a/addon/components/customer-panel/orders.js b/addon/components/customer-panel/orders.js index a75248c..795d3f4 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,39 @@ 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 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/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/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..4e687a4 --- /dev/null +++ b/addon/components/storefront/order/details/activity.js @@ -0,0 +1,69 @@ +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; + @tracked layout = this.appCache.get('storefront:order:activity:layout', 'timeline'); + + 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); + } + + /* eslint-disable ember/no-side-effects */ + get actionButtons() { + return [ + { + items: [ + { + 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('storefront:order:activity:layout', 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..a2121f7 --- /dev/null +++ b/addon/components/storefront/order/details/detail.hbs @@ -0,0 +1,69 @@ + +
+
+
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)}} +
+
+
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..debce9e --- /dev/null +++ b/addon/components/storefront/order/details/detail.js @@ -0,0 +1,31 @@ +import Component from '@glimmer/component'; + +export default class StorefrontOrderDetailsDetailComponent extends Component { + 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/tracking-stop-progress.hbs b/addon/components/tracking-stop-progress.hbs new file mode 100644 index 0000000..60b5ae6 --- /dev/null +++ b/addon/components/tracking-stop-progress.hbs @@ -0,0 +1,45 @@ +
+
+
Between Stops
+
+ {{this.completedCount}} + / + {{this.totalCount}} + stops +
+
+ +
+
+ {{#each this.stops as |stop|}} +
+
+ {{#unless stop.isFirst}} +
+ {{/unless}} + +
+ {{stop.label}} +
+ + +
{{stop.title}}
+ +
+
+ + {{#unless stop.isLast}} +
+ {{/unless}} +
+
+ {{/each}} +
+
+
diff --git a/addon/components/tracking-stop-progress.js b/addon/components/tracking-stop-progress.js new file mode 100644 index 0000000..52d31de --- /dev/null +++ b/addon/components/tracking-stop-progress.js @@ -0,0 +1,79 @@ +import Component from '@glimmer/component'; + +export default class TrackingStopProgressComponent extends Component { + get stops() { + const stops = this.args.stops ?? []; + const activeStop = this.args.activeStop; + + return stops.map((stop, index) => { + const isActive = this.matches(stop, activeStop); + const completed = Boolean(stop.completed); + + return { + ...stop, + index: index + 1, + isFirst: index === 0, + isLast: index === stops.length - 1, + label: this.labelFor(stop, index), + title: this.titleFor(stop, index), + locationLabel: this.locationLabelFor(stop, index), + place: this.placeFor(stop), + completed, + active: isActive, + pending: !completed && !isActive, + }; + }); + } + + get completedCount() { + return this.stops.filter((stop) => stop.completed).length; + } + + get totalCount() { + return this.stops.length; + } + + labelFor(stop, index) { + if (stop.type === 'pickup') { + return 'P'; + } + + if (stop.type === 'dropoff') { + return 'D'; + } + + return String(index + 1); + } + + titleFor(stop, index) { + if (stop.type === 'pickup') { + return 'Pickup'; + } + + if (stop.type === 'dropoff') { + return 'Dropoff'; + } + + return `Stop ${index + 1}`; + } + + locationLabelFor(stop, index) { + return stop.city || stop.name || stop.address || this.titleFor(stop, index); + } + + placeFor(stop) { + return { + ...stop, + street1: stop.street1 || stop.address || stop.name, + country_name: stop.country_name || stop.country, + }; + } + + matches(stop, activeStop) { + if (!stop || !activeStop) { + return false; + } + + return stop.uuid === activeStop.uuid || stop.public_id === activeStop.public_id; + } +} diff --git a/addon/components/widget/orders.js b/addon/components/widget/orders.js index 5118439..bccc297 100644 --- a/addon/components/widget/orders.js +++ b/addon/components/widget/orders.js @@ -12,8 +12,7 @@ export default class WidgetOrdersComponent extends Component { @service intl; @service appCache; @service modalsManager; - @service contextPanel; - @service orderActions; + @service storefrontOrderActions; @service notifications; @tracked orders = []; @tracked title = this.intl.t('storefront.component.widget.orders.widget-title'); @@ -98,41 +97,45 @@ export default class WidgetOrdersComponent extends Component { } @action async viewOrder(order) { - this.contextPanel.focus(order, 'viewing'); + return this.storefrontOrderActions.viewOrder(order, { + onChange: () => { + 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 markAsPreparing(order) { - this.orderActions.markAsPreparing(order, () => { + this.storefrontOrderActions.markAsPreparing(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/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index e50c06f..838feef 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -48,7 +48,7 @@ export default class NetworksIndexNetworkOrdersController extends Controller { */ @service filters; - @service contextPanel; + @service storefrontOrderActions; /** * Queryable parameters for this controller's model @@ -320,6 +320,6 @@ export default class NetworksIndexNetworkOrdersController extends Controller { } @action viewOrder(order) { - this.contextPanel.focus(order, 'viewing'); + return this.storefrontOrderActions.viewOrder(order); } } diff --git a/addon/controllers/orders/index.js b/addon/controllers/orders/index.js index dc28955..b122765 100644 --- a/addon/controllers/orders/index.js +++ b/addon/controllers/orders/index.js @@ -335,7 +335,7 @@ export default class OrdersIndexController extends BaseController { modal.startLoading(); try { - await this.fetch.patch('orders/cancel', { order: order.id }); + await this.fetch.patch('orders/cancel', { order: order.id }, { namespace: 'storefront/int/v1' }); order.set('status', 'canceled'); this.notifications.success(this.intl.t('fleet-ops.operations.orders.index.cancel-success', { orderId: order.public_id })); modal.done(); diff --git a/addon/controllers/orders/index/view.js b/addon/controllers/orders/index/view.js index afb5f11..0ac7c1c 100644 --- a/addon/controllers/orders/index/view.js +++ b/addon/controllers/orders/index/view.js @@ -1,7 +1,33 @@ import { action } from '@ember/object'; import BaseController from '../../base-controller'; +import { inject as service } from '@ember/service'; +import { isArray } from '@ember/array'; export default class OrdersIndexViewController extends BaseController { + @service storefrontOrderActions; + @service('universe/menu-service') menuService; + + get tabs() { + const registeredTabs = this.menuService.getMenuItems('storefront:component:order:details'); + + return [ + { + route: 'orders.index.view.index', + label: 'Overview', + icon: 'folder-open', + }, + ...(isArray(registeredTabs) ? registeredTabs : []), + ]; + } + + get actionButtons() { + return this.storefrontOrderActions.actionButtonsFor(this.model, this.refreshOrder); + } + + @action refreshOrder() { + return this.hostRouter.refresh(); + } + /** * Uses router service to transition back to `orders.index` * diff --git a/addon/controllers/orders/index/view/index.js b/addon/controllers/orders/index/view/index.js new file mode 100644 index 0000000..5dd1df4 --- /dev/null +++ b/addon/controllers/orders/index/view/index.js @@ -0,0 +1,5 @@ +import Controller, { inject as controller } from '@ember/controller'; + +export default class OrdersIndexViewIndexController extends Controller { + @controller('orders.index.view') parent; +} diff --git a/addon/routes.js b/addon/routes.js index e83dacc..05274a5 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -26,7 +26,10 @@ export default buildRoutes(function () { this.route('index', { path: '/' }, function () { this.route('new'); this.route('edit', { path: '/:public_id' }); - this.route('view', { path: '/:public_id' }); + this.route('view', { path: '/:public_id' }, function () { + this.route('index', { path: '/' }); + this.route('virtual', { path: '/:slug' }); + }); }); }); this.route('networks', function () { diff --git a/addon/routes/orders/index/view.js b/addon/routes/orders/index/view.js index a8fc2a0..8c6a015 100644 --- a/addon/routes/orders/index/view.js +++ b/addon/routes/orders/index/view.js @@ -3,10 +3,9 @@ import { inject as service } from '@ember/service'; import { action } from '@ember/object'; export default class OrdersIndexViewRoute extends Route { - @service currentUser; @service notifications; - @service store; - @service socket; + @service storefront; + @service fetch; @service intl; @service abilities; @service hostRouter; @@ -23,11 +22,17 @@ export default class OrdersIndexViewRoute extends Route { } model({ public_id }) { - const order = this.store.queryRecord('order', { - public_id, - single: true, - with: ['payload', 'driverAssigned', 'orderConfig', 'customer', 'facilitator', 'trackingStatuses', 'trackingNumber', 'purchaseRate', 'comments', 'files'], - }); + const order = this.fetch.get( + `orders/${public_id}`, + { + storefront: this.storefront.getActiveStore('public_id'), + }, + { + namespace: 'storefront/int/v1', + normalizeToEmberData: true, + normalizeModelType: 'order', + } + ); return order; } diff --git a/addon/routes/orders/index/view/virtual.js b/addon/routes/orders/index/view/virtual.js new file mode 100644 index 0000000..d91cbd5 --- /dev/null +++ b/addon/routes/orders/index/view/virtual.js @@ -0,0 +1,24 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class OrdersIndexViewVirtualRoute extends Route { + @service universe; + @service('universe/menu-service') menuService; + + queryParams = { + view: { + refreshModel: true, + }, + }; + + model({ section = null, slug }, transition) { + const view = this.universe.getViewFromTransition(transition); + return this.menuService.lookupMenuItem('storefront:component:order:details', slug, view, section); + } + + setupController(controller) { + super.setupController(...arguments); + controller.resource = this.modelFor('orders.index.view'); + controller.detailsController = this.controllerFor('orders.index.view'); + } +} diff --git a/addon/services/context-panel.js b/addon/services/context-panel.js index f9faebe..88c46b4 100644 --- a/addon/services/context-panel.js +++ b/addon/services/context-panel.js @@ -25,12 +25,6 @@ export default class ContextPanelService extends Service { componentArguments: [{ isResizable: true }, { width: '500px' }], }, }, - order: { - viewing: { - component: 'order-panel', - componentArguments: [{ isResizable: true }, { width: '500px' }], - }, - }, }; /** diff --git a/addon/services/order-actions.js b/addon/services/order-actions.js index 0fefe54..4597fe2 100644 --- a/addon/services/order-actions.js +++ b/addon/services/order-actions.js @@ -1,279 +1,3 @@ -import Service, { inject as service } from '@ember/service'; -import toBoolean from '@fleetbase/ember-core/utils/to-boolean'; +import StorefrontOrderActionsService from './storefront-order-actions'; -export default class OrderActionsService extends Service { - @service intl; - @service notifications; - @service fetch; - @service store; - @service modalsManager; - @service storefront; - - cancelOrder(order, callback) { - this.modalsManager.confirm({ - title: this.intl.t('fleet-ops.operations.orders.index.cancel-title'), - body: this.intl.t('fleet-ops.operations.orders.index.cancel-body'), - order, - confirm: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.patch('orders/cancel', { order: order.id }); - order.set('status', 'canceled'); - this.notifications.success('Order canceled.'); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (error) { - this.notifications.serverError(error); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - async assignDriver(order, callback) { - 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: async (modal) => { - modal.startLoading(); - - try { - await order.save(); - this.notifications.success('Driver assigned to order.'); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - async acceptOrder(order, callback) { - const store = 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', - modalClass: 'scrollable-height-dialog', - order, - store, - assignDriver: async () => { - await this.modalsManager.done(); - this.assignDriver(order, (order) => { - this.acceptOrder(order); - }); - }, - confirm: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post('orders/accept', { order: order.id }, { namespace: 'storefront/int/v1' }); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - markAsReady(order, callback) { - // for pickup orders - if (order.meta && toBoolean(order.meta.is_pickup) === true) { - return 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: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post('orders/ready', { order: order.id }, { namespace: 'storefront/int/v1' }); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - 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: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post('orders/ready', { order: order.id, driver: modal.getOption('driver.id'), adhoc: modal.getOption('adhoc') }, { namespace: 'storefront/int/v1' }); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - 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: async (modal) => { - modal.startLoading(); - try { - await this.fetch.post('orders/ready', { order: order.id }, { namespace: 'storefront/int/v1' }); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - markAsPreparing(order, callback) { - this.modalsManager.confirm({ - title: this.intl.t('storefront.component.widget.orders.mark-as-preparing-modal-title'), - body: this.intl.t('storefront.component.widget.orders.mark-as-preparing-modal-body'), - acceptButtonText: this.intl.t('storefront.component.widget.orders.mark-as-preparing-accept-button-text'), - acceptButtonIcon: 'check', - acceptButtonScheme: 'success', - confirm: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post('orders/preparing', { order: order.id }, { namespace: 'storefront/int/v1' }); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } - - markAsCompleted(order, callback) { - 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: async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post('orders/completed', { order: order.id }, { namespace: 'storefront/int/v1' }); - modal.done(); - if (typeof callback === 'function') { - callback(order); - } - } catch (err) { - this.notifications.serverError(err); - } finally { - modal.stopLoading(); - } - }, - decline: async (modal) => { - if (typeof callback === 'function') { - callback(order); - } - modal.done(); - }, - }); - } -} +export default class OrderActionsService extends StorefrontOrderActionsService {} diff --git a/addon/services/storefront-order-actions.js b/addon/services/storefront-order-actions.js new file mode 100644 index 0000000..002833f --- /dev/null +++ b/addon/services/storefront-order-actions.js @@ -0,0 +1,378 @@ +import Service, { inject as service } from '@ember/service'; +import { isArray } from '@ember/array'; +import toBoolean from '@fleetbase/ember-core/utils/to-boolean'; + +export default class StorefrontOrderActionsService extends Service { + @service intl; + @service notifications; + @service fetch; + @service modalsManager; + @service storefront; + @service resourceContextPanel; + @service('universe/menu-service') menuService; + + async viewOrder(order, options = {}) { + const hydratedOrder = await this.fetch.get( + `orders/${order.public_id ?? order.id}`, + { + storefront: this.storefront.getActiveStore('public_id'), + }, + { + namespace: 'storefront/int/v1', + normalizeToEmberData: true, + normalizeModelType: 'order', + } + ); + + this.resourceContextPanel.open({ + id: `storefront-order:${hydratedOrder.id}`, + resource: hydratedOrder, + title: hydratedOrder.public_id, + header: 'storefront/order/panel-header', + tabs: this.tabsFor(hydratedOrder), + actionButtons: this.actionButtonsFor(hydratedOrder, options.onChange), + width: '560px', + size: 'sm', + dismissible: false, + bodyClass: 'scrollable', + headerClass: 'storefront-order-panel-header no-bottom-border', + panelContentClass: 'storefront-order-panel-content', + }); + + return hydratedOrder; + } + + tabsFor() { + const registeredTabs = this.menuService.getMenuItems('storefront:component:order:details'); + + return [ + { + label: 'Overview', + key: 'overview', + icon: 'folder-open', + component: 'storefront/order/details', + }, + ...(isArray(registeredTabs) + ? registeredTabs.map((tab) => ({ + label: tab.label ?? tab.title, + key: tab.slug, + icon: tab.icon, + component: 'storefront/order/details/registered-tab', + class: tab.slug, + })) + : []), + ]; + } + + actionButtonsFor(order, callback) { + return [ + { + items: [ + order.isFresh + ? { + text: 'Accept order', + icon: 'check', + fn: () => this.acceptOrder(order, callback), + } + : null, + order.isPreparing + ? { + text: 'Mark ready', + icon: 'bell-concierge', + fn: () => this.markAsReady(order, callback), + } + : null, + order.isPickupReady + ? { + text: 'Complete order', + icon: 'check', + fn: () => this.markAsCompleted(order, callback), + } + : null, + { + text: order.has_driver_assigned ? 'Change driver' : 'Assign driver', + icon: 'id-card', + disabled: order.isCanceled || order.status === 'order_canceled', + fn: () => this.assignDriver(order, callback), + }, + { + separator: true, + }, + { + text: 'Cancel order', + icon: 'ban', + class: 'text-danger', + disabled: order.isCanceled || order.status === 'order_canceled', + fn: () => this.cancelOrder(order, callback), + }, + ].filter(Boolean), + }, + ]; + } + + cancelOrder(order, callback) { + this.modalsManager.confirm({ + title: this.intl.t('fleet-ops.operations.orders.index.cancel-title'), + body: this.intl.t('fleet-ops.operations.orders.index.cancel-body'), + order, + confirm: async (modal) => { + modal.startLoading(); + + try { + await this.fetch.patch('orders/cancel', { order: order.id }, { namespace: 'storefront/int/v1' }); + order.set('status', 'canceled'); + this.notifications.success('Order canceled.'); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (error) { + this.notifications.serverError(error); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + async assignDriver(order, callback) { + 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: async (modal) => { + modal.startLoading(); + + try { + await order.save(); + this.notifications.success('Driver assigned to order.'); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + async acceptOrder(order, callback) { + const store = 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', + modalClass: 'scrollable-height-dialog', + order, + store, + assignDriver: async () => { + await this.modalsManager.done(); + this.assignDriver(order, (order) => { + this.acceptOrder(order); + }); + }, + confirm: async (modal) => { + modal.startLoading(); + + try { + await this.fetch.post('orders/accept', { order: order.id }, { namespace: 'storefront/int/v1' }); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + markAsReady(order, callback) { + if (order.meta && toBoolean(order.meta.is_pickup) === true) { + return 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: async (modal) => { + modal.startLoading(); + + try { + await this.fetch.post('orders/ready', { order: order.id }, { namespace: 'storefront/int/v1' }); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + if (!order.adhoc) { + 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: async (modal) => { + modal.startLoading(); + + try { + await this.fetch.post('orders/ready', { order: order.id, driver: modal.getOption('driver.id'), adhoc: modal.getOption('adhoc') }, { namespace: 'storefront/int/v1' }); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + 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: async (modal) => { + modal.startLoading(); + try { + await this.fetch.post('orders/ready', { order: order.id }, { namespace: 'storefront/int/v1' }); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + markAsPreparing(order, callback) { + this.modalsManager.confirm({ + title: this.intl.t('storefront.component.widget.orders.mark-as-preparing-modal-title'), + body: this.intl.t('storefront.component.widget.orders.mark-as-preparing-modal-body'), + acceptButtonText: this.intl.t('storefront.component.widget.orders.mark-as-preparing-accept-button-text'), + acceptButtonIcon: 'check', + acceptButtonScheme: 'success', + confirm: async (modal) => { + modal.startLoading(); + + try { + await this.fetch.post('orders/preparing', { order: order.id }, { namespace: 'storefront/int/v1' }); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } + + markAsCompleted(order, callback) { + 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: async (modal) => { + modal.startLoading(); + + try { + await this.fetch.post('orders/completed', { order: order.id }, { namespace: 'storefront/int/v1' }); + modal.done(); + if (typeof callback === 'function') { + callback(order); + } + } catch (err) { + this.notifications.serverError(err); + } finally { + modal.stopLoading(); + } + }, + decline: async (modal) => { + if (typeof callback === 'function') { + callback(order); + } + modal.done(); + }, + }); + } +} diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 99efc94..4817961 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -385,9 +385,9 @@ body[data-theme='dark'] .storefront-widget-empty { display: flex; align-items: center; gap: 0.75rem; - min-height: 3.35rem; + min-height: 3rem; border-radius: 0.375rem; - padding: 0.55rem 0.65rem; + padding: 0.45rem 0.65rem; } .storefront-list-row:hover { @@ -871,3 +871,1099 @@ body[data-theme='dark'] .storefront-customer-card-details { gap: 0.4rem; margin-top: 0.8rem; } + +.storefront-order-panel-header { + border-bottom: 1px solid #e5e7eb; + background: #fff; +} + +body[data-theme='dark'] .storefront-order-panel-header { + border-color: #1f2937; + background: #111827; +} + +.storefront-order-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; +} + +.storefront-order-panel-heading__main, +.storefront-order-panel-heading__actions, +.storefront-order-person { + display: flex; + align-items: flex-start; +} + +.storefront-order-panel-heading__main { + min-width: 0; + align-items: flex-start; + gap: 0.75rem; +} + +.storefront-order-panel-heading__actions { + gap: 0.5rem; +} + +.storefront-order-panel-heading__badges { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; +} + +.storefront-order-panel-heading__fulfillment-badge .status-badge-inner-wrap { + min-width: 4.5rem; + justify-content: center; +} + +.storefront-order-panel-heading__icon { + display: inline-flex; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + background: #ecfdf5; + color: #059669; +} + +body[data-theme='dark'] .storefront-order-panel-heading__icon { + background: rgb(16 185 129 / 12%); + color: #34d399; +} + +.storefront-order-panel-heading__eyebrow, +.storefront-order-detail-card__label, +.storefront-order-detail-block__label, +.storefront-order-snapshot span, +.storefront-order-facts span, +.storefront-order-totals span { + color: #64748b; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +body[data-theme='dark'] .storefront-order-panel-heading__eyebrow, +body[data-theme='dark'] .storefront-order-detail-card__label, +body[data-theme='dark'] .storefront-order-detail-block__label, +body[data-theme='dark'] .storefront-order-snapshot span, +body[data-theme='dark'] .storefront-order-facts span, +body[data-theme='dark'] .storefront-order-totals span { + color: #94a3b8; +} + +.storefront-order-panel-heading__title { + overflow: hidden; + color: #111827; + font-size: 1rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-order-panel-heading__title { + color: #f8fafc; +} + +.storefront-order-panel-heading__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + color: #64748b; + font-size: 0.76rem; + font-weight: 600; +} + +body[data-theme='dark'] .storefront-order-panel-heading__meta { + color: #94a3b8; +} + +.storefront-order-panel-tablist { + border-bottom: 1px solid #e5e7eb; +} + +body[data-theme='dark'] .storefront-order-panel-tablist { + border-color: #1f2937; + background: #111827; +} + +.storefront-order-panel-tab-content { + height: calc(100vh - 8.75rem); + padding: 0; +} + +.storefront-order-details { + display: flex; + flex-direction: column; +} + +.storefront-order-snapshot, +.storefront-order-customer, +.storefront-order-fulfillment, +.storefront-order-commerce, +.storefront-order-tracking { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.storefront-order-snapshot__total { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + border-radius: 0.5rem; + background: #f8fafc; + padding: 0.85rem; +} + +body[data-theme='dark'] .storefront-order-snapshot__total { + background: #0f172a; +} + +.storefront-order-snapshot__total strong { + color: #059669; + font-size: 1.45rem; + font-weight: 900; +} + +.storefront-order-snapshot__grid, +.storefront-order-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.storefront-order-snapshot__grid > div, +.storefront-order-detail-card, +.storefront-order-detail-block { + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + background: #fff; + padding: 0.75rem; +} + +body[data-theme='dark'] .storefront-order-snapshot__grid > div, +body[data-theme='dark'] .storefront-order-detail-card, +body[data-theme='dark'] .storefront-order-detail-block { + border-color: #263241; + background: #111827; +} + +.storefront-order-snapshot__grid strong, +.storefront-order-facts strong, +.storefront-order-totals strong { + color: #111827; + font-size: 0.82rem; + font-weight: 800; +} + +body[data-theme='dark'] .storefront-order-snapshot__grid strong, +body[data-theme='dark'] .storefront-order-facts strong, +body[data-theme='dark'] .storefront-order-totals strong { + color: #f8fafc; +} + +.storefront-order-person { + display: flex; + align-items: flex-start; + min-width: 0; + gap: 0.75rem; +} + +.storefront-order-person--compact { + align-items: flex-start; +} + +.storefront-order-store { + display: flex; + align-items: flex-start; + gap: 0.9rem; +} + +.storefront-order-person__avatar { + width: 2.75rem; + height: 2.75rem; + flex: 0 0 auto; + border-radius: 0.5rem; + object-fit: cover; +} + +.storefront-order-person__name { + overflow: hidden; + color: #111827; + font-size: 0.88rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-order-person__name { + color: #f8fafc; +} + +.storefront-order-person__meta { + overflow: hidden; + color: #64748b; + font-size: 0.75rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-order-person__meta { + color: #94a3b8; +} + +.storefront-order-facts, +.storefront-order-totals { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.storefront-order-facts > div, +.storefront-order-totals > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.storefront-order-items { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.storefront-order-item { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + gap: 0.65rem; + align-items: start; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 0.65rem; +} + +body[data-theme='dark'] .storefront-order-item { + border-color: #263241; +} + +.storefront-order-item__qty { + border: 1px solid #cbd5e1; + border-radius: 0.375rem; + padding: 0.1rem 0.35rem; + color: #334155; + font-size: 0.7rem; + font-weight: 900; +} + +body[data-theme='dark'] .storefront-order-item__qty { + border-color: #475569; + color: #cbd5e1; +} + +.storefront-order-item__image { + width: 2.75rem; + height: 2.75rem; + border-radius: 0.5rem; + object-fit: cover; +} + +.storefront-order-item__name { + color: #111827; + font-size: 0.85rem; + font-weight: 800; +} + +body[data-theme='dark'] .storefront-order-item__name { + color: #f8fafc; +} + +.storefront-order-item__description, +.storefront-order-item__options { + color: #64748b; + font-size: 0.72rem; + font-weight: 600; +} + +body[data-theme='dark'] .storefront-order-item__description, +body[data-theme='dark'] .storefront-order-item__options { + color: #94a3b8; +} + +.storefront-order-item__price { + justify-self: end; + color: #059669; + font-size: 0.8rem; + font-weight: 900; + text-align: right; + white-space: nowrap; +} + +.storefront-order-totals { + border-top: 1px solid #e5e7eb; + padding-top: 0.5rem; +} + +body[data-theme='dark'] .storefront-order-totals { + border-color: #263241; +} + +.storefront-order-totals__total strong { + color: #059669; + font-size: 1rem; +} + +.storefront-order-labels { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.storefront-order-label { + border-radius: 0.5rem; + background: #fff; + padding: 0.5rem; +} + +.storefront-order-label img { + width: 4rem; + height: 4rem; +} + +.storefront-order-label--barcode img { + width: 10rem; +} + +.storefront-order-documents { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.storefront-order-empty-state { + display: flex; + min-height: 7rem; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + color: #64748b; + font-size: 0.82rem; + font-weight: 800; + text-align: center; +} + +body[data-theme='dark'] .storefront-order-empty-state { + color: #94a3b8; +} + +@media (width <= 640px) { + .storefront-order-panel-heading { + flex-direction: column; + } + + .storefront-order-snapshot__grid, + .storefront-order-card-grid, + .storefront-order-documents { + grid-template-columns: minmax(0, 1fr); + } + + .storefront-order-item { + grid-template-columns: auto minmax(0, 1fr); + } + + .storefront-order-item__image, + .storefront-order-item__price { + display: none; + } +} + +.storefront-order-store .storefront-order-store__image { + width: 4rem; + height: 4rem; + border-radius: 0.55rem; + object-fit: cover; +} + +.storefront-order-store__content { + min-width: 0; + flex: 1 1 auto; +} + +.storefront-order-store__rows { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.55rem; +} + +.storefront-order-store__row { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.storefront-order-store__row-label { + color: #64748b; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +body[data-theme='dark'] .storefront-order-store__row-label { + color: #94a3b8; +} + +.storefront-order-store__row-value { + min-width: 0; + color: #111827; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.35; +} + +.storefront-order-store__row-value a { + color: #2563eb; +} + +.storefront-order-store__row-value a:hover { + color: #1d4ed8; +} + +body[data-theme='dark'] .storefront-order-store__row-value { + color: #e5e7eb; +} + +body[data-theme='dark'] .storefront-order-store__row-value a { + color: #60a5fa; +} + +body[data-theme='dark'] .storefront-order-store__row-value a:hover { + color: #93c5fd; +} + +.storefront-order-store__address address { + font-style: normal; +} + +.tracking-intelligence { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tracking-intelligence__status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + font-size: 0.72rem; +} + +.tracking-intelligence-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.125rem 0.5rem; + border: 1px solid transparent; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.35; + white-space: nowrap; +} + +.tracking-intelligence-pill__dot { + width: 0.35rem; + height: 0.35rem; + border-radius: 9999px; + background: currentcolor; +} + +.tracking-intelligence-pill--good { + color: #16a34a; + background: rgba(34, 197, 94, 10%); + border-color: rgba(34, 197, 94, 24%); +} + +.tracking-intelligence-pill--warn { + color: #d97706; + background: rgba(245, 158, 11, 12%); + border-color: rgba(245, 158, 11, 28%); +} + +.tracking-intelligence-pill--bad { + color: #dc2626; + background: rgba(239, 68, 68, 11%); + border-color: rgba(239, 68, 68, 28%); +} + +.tracking-intelligence-pill--muted { + color: #6b7280; + background: rgba(107, 114, 128, 10%); + border-color: rgba(107, 114, 128, 22%); +} + +[data-theme='dark'] .tracking-intelligence-pill--good { + color: #5bc796; + background: rgba(91, 199, 150, 12%); +} + +[data-theme='dark'] .tracking-intelligence-pill--warn { + color: #e5a14b; + background: rgba(229, 161, 75, 12%); + border-color: rgba(229, 161, 75, 32%); +} + +[data-theme='dark'] .tracking-intelligence-pill--bad { + color: #e26b6b; + background: rgba(226, 107, 107, 13%); + border-color: rgba(226, 107, 107, 32%); +} + +[data-theme='dark'] .tracking-intelligence-pill--muted { + color: #7c8896; + background: rgba(255, 255, 255, 4%); + border-color: #232c38; +} + +.tracking-intelligence-alert { + display: grid; + grid-template-columns: 1.125rem minmax(0, 1fr) auto; + gap: 0.625rem; + align-items: flex-start; + padding: 0.625rem 0.7rem; + border: 1px solid rgba(245, 158, 11, 28%); + border-radius: 0.5rem; + background: rgba(245, 158, 11, 10%); + color: #92400e; + font-size: 0.75rem; +} + +.tracking-intelligence-alert--critical { + border-color: rgba(239, 68, 68, 28%); + background: rgba(239, 68, 68, 10%); + color: #991b1b; +} + +.tracking-intelligence-alert__icon { + display: flex; + width: 1.125rem; + height: 1.125rem; + align-items: center; + justify-content: center; + border-radius: 0.3125rem; + background: rgba(0, 0, 0, 10%); +} + +.tracking-intelligence-alert__title { + color: #111827; + font-weight: 700; +} + +.tracking-intelligence-alert__body { + margin-top: 0.125rem; + color: #78350f; + line-height: 1.35; +} + +[data-theme='dark'] .tracking-intelligence-alert { + border-color: rgba(229, 161, 75, 32%); + background: rgba(229, 161, 75, 12%); + color: #e5a14b; +} + +[data-theme='dark'] .tracking-intelligence-alert--critical { + border-color: rgba(226, 107, 107, 32%); + background: rgba(226, 107, 107, 13%); + color: #e26b6b; +} + +[data-theme='dark'] .tracking-intelligence-alert__title { + color: #eceff4; +} + +[data-theme='dark'] .tracking-intelligence-alert__body { + color: #b6bfca; +} + +.tracking-intelligence__eta-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.5rem; +} + +.tracking-intelligence-cell, +.tracking-intelligence-destination, +.tracking-intelligence-distance, +.tracking-intelligence-diagnostics, +.tracking-intelligence-labels, +.tracking-stop-progress { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + background: #f9fafb; + box-shadow: 0 1px 2px rgba(15, 23, 42, 8%); +} + +[data-theme='dark'] .tracking-intelligence-cell, +[data-theme='dark'] .tracking-intelligence-destination, +[data-theme='dark'] .tracking-intelligence-distance, +[data-theme='dark'] .tracking-intelligence-diagnostics, +[data-theme='dark'] .tracking-intelligence-labels, +[data-theme='dark'] .tracking-stop-progress { + border-color: #374151; + background: #1f2937; + box-shadow: 0 8px 20px rgba(0, 0, 0, 18%); +} + +.tracking-intelligence-cell { + min-width: 0; + padding: 0.6rem 0.7rem; +} + +.tracking-intelligence-cell--accent { + border-color: rgba(245, 158, 11, 32%); + background: linear-gradient(180deg, rgba(245, 158, 11, 8%), transparent), #f9fafb; +} + +[data-theme='dark'] .tracking-intelligence-cell--accent { + border-color: rgba(229, 161, 75, 32%); + background: linear-gradient(180deg, rgba(229, 161, 75, 8%), transparent), #1f2937; +} + +.tracking-intelligence-cell__label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.3rem; + color: #6b7280; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tracking-intelligence-cell__value { + overflow: hidden; + color: #111827; + font-size: 1rem; + font-weight: 700; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tracking-intelligence-cell--accent .tracking-intelligence-cell__value { + color: #d97706; +} + +.tracking-intelligence-cell__value--muted { + color: #6b7280; + text-decoration: line-through; + text-decoration-color: #ef4444; + text-decoration-thickness: 1px; +} + +.tracking-intelligence-cell__sub { + margin-top: 0.2rem; + overflow: hidden; + color: #6b7280; + font-size: 0.68rem; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tracking-intelligence-cell__sub--warn { + color: #b45309; + white-space: normal; +} + +.tracking-intelligence-tag { + padding: 0.0625rem 0.3rem; + border-radius: 0.2rem; + background: rgba(245, 158, 11, 12%); + color: #d97706; + font-size: 0.55rem; + font-weight: 800; +} + +.tracking-intelligence-confidence { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; + font-size: 0.7rem; +} + +.tracking-intelligence-confidence__label, +.tracking-intelligence-confidence__value { + color: #6b7280; + font-weight: 700; + text-transform: uppercase; +} + +.tracking-intelligence-confidence__segments { + display: flex; + gap: 0.125rem; +} + +.tracking-intelligence-confidence__segments span { + height: 0.25rem; + flex: 1; + border-radius: 0.125rem; + background: #e5e7eb; +} + +.tracking-intelligence-confidence__segments .is-lit { + background: #d97706; +} + +.tracking-intelligence-confidence__segments.tracking-intelligence-pill--good .is-lit { + background: #16a34a; +} + +.tracking-intelligence-confidence__segments.tracking-intelligence-pill--bad .is-lit { + background: #dc2626; +} + +.tracking-intelligence-destination { + display: flex; + gap: 0.625rem; + align-items: flex-start; + padding: 0.65rem 0.7rem; +} + +.tracking-intelligence-destination__marker { + display: flex; + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: #f59e0b; + color: #1f1300; + font-size: 0.7rem; + font-weight: 800; +} + +.tracking-intelligence-destination__label { + color: #6b7280; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tracking-intelligence-destination__address { + margin-top: 0.15rem; + color: #111827; + font-size: 0.82rem; + font-weight: 700; + line-height: 1.3; +} + +.tracking-intelligence-destination__eta { + margin-top: 0.2rem; + color: #6b7280; + font-size: 0.7rem; +} + +.tracking-intelligence-distance { + padding: 0.6rem 0.7rem; +} + +.tracking-intelligence-distance__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + color: #6b7280; + font-size: 0.72rem; +} + +.tracking-intelligence-distance__row + .tracking-intelligence-distance__row { + margin-top: 0.35rem; +} + +.tracking-intelligence-distance__row strong { + color: #111827; +} + +.tracking-intelligence-distance__bar { + grid-column: 1 / -1; + height: 0.32rem; + overflow: hidden; + border-radius: 9999px; + background: #e5e7eb; +} + +.tracking-intelligence-distance__bar span { + display: block; + height: 100%; + border-radius: inherit; + background: #f59e0b; +} + +.tracking-intelligence-distance__bar--secondary span { + background: #38bdf8; +} + +.tracking-intelligence-distance__foot { + margin-top: 0.5rem; + padding-top: 0.45rem; + border-top: 1px dashed #d1d5db; + color: #9ca3af; + font-size: 0.65rem; +} + +.tracking-intelligence-diagnostics { + overflow: hidden; +} + +.tracking-intelligence-diagnostics summary { + display: flex; + cursor: pointer; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.7rem; + color: #6b7280; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.08em; + list-style: none; + text-transform: uppercase; +} + +.tracking-intelligence-diagnostics__summary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.tracking-intelligence-diagnostics__summary svg { + transition: transform 160ms ease; +} + +.tracking-intelligence-diagnostics[open] .tracking-intelligence-diagnostics__summary svg { + transform: rotate(90deg); +} + +.tracking-intelligence-diagnostics summary::-webkit-details-marker { + display: none; +} + +.tracking-intelligence-diagnostics__body { + padding: 0 0.7rem 0.7rem; +} + +.tracking-intelligence-diagnostics__row { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.28rem 0; + border-top: 1px dashed #e5e7eb; + color: #6b7280; + font-size: 0.72rem; +} + +.tracking-intelligence-diagnostics__row strong { + color: #111827; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.tracking-intelligence-diagnostics__warnings { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding-top: 0.5rem; +} + +.tracking-intelligence-diagnostics__warnings span { + padding: 0.125rem 0.375rem; + border: 1px solid rgba(245, 158, 11, 25%); + border-radius: 0.25rem; + background: rgba(245, 158, 11, 10%); + color: #b45309; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.62rem; +} + +.tracking-stop-progress { + padding: 0.65rem 0.7rem; +} + +.tracking-stop-progress__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.55rem; +} + +.tracking-stop-progress__title { + color: #6b7280; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.tracking-stop-progress__count { + color: #6b7280; + font-size: 0.7rem; + white-space: nowrap; +} + +.tracking-stop-progress__scroller { + overflow: auto hidden; + min-height: 2rem; + padding: 0.2rem 0 0.05rem; +} + +.tracking-stop-progress__rail { + display: flex; + min-width: 100%; +} + +.tracking-stop-progress__stop { + min-width: 2.5rem; + flex: 1; +} + +.tracking-stop-progress__line { + display: flex; + align-items: center; +} + +.tracking-stop-progress__connector { + height: 1px; + flex: 1; + border-top: 1px dashed #d1d5db; +} + +.tracking-stop-progress__dot { + position: relative; + z-index: 1; + display: flex; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1px solid #d1d5db; + border-radius: 9999px; + background: #f3f4f6; + color: #6b7280; + font-size: 0.58rem; + font-weight: 800; +} + +.tracking-stop-progress__dot--done { + border-color: #22c55e; + background: #22c55e; + color: #06210f; +} + +.tracking-stop-progress__dot--active { + border-color: #f59e0b; + background: #f59e0b; + color: #1f1300; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 14%); +} + +.tracking-stop-progress__dot--active::before, +.fleetops-route-stop-badge--active::before { + position: absolute; + inset: -0.45rem; + border: 1.5px solid currentcolor; + border-radius: 9999px; + content: ''; + opacity: 0.65; + animation: fleetops-tracking-pulse 2.2s ease-out infinite; +} + +.fleetops-route-stop-badge--active { + position: relative; + overflow: visible !important; +} + +@keyframes fleetops-tracking-pulse { + 0% { + opacity: 0.7; + transform: scale(0.72); + } + + 100% { + opacity: 0; + transform: scale(1.65); + } +} + +.tracking-intelligence-alert__cta, +.tracking-intelligence-footer__button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border: 1px solid rgba(245, 158, 11, 32%); + border-radius: 0.375rem; + background: rgba(245, 158, 11, 8%); + color: #b45309; + font-size: 0.7rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.tracking-intelligence-alert__cta:disabled, +.tracking-intelligence-footer__button:disabled { + cursor: progress; + opacity: 0.7; +} + +.tracking-intelligence-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + color: #9ca3af; + font-size: 0.68rem; +} + +.tracking-intelligence-labels { + display: flex; + flex-direction: column; + padding: 0.75rem; +} + +@media (width <= 767px) { + .tracking-intelligence__eta-grid { + grid-template-columns: 1fr; + } + + .tracking-intelligence-alert { + grid-template-columns: 1.125rem minmax(0, 1fr); + } + + .tracking-intelligence-alert__cta { + grid-column: 1 / -1; + justify-content: center; + } +} diff --git a/addon/templates/application.hbs b/addon/templates/application.hbs index 378d5e8..57c37e3 100644 --- a/addon/templates/application.hbs +++ b/addon/templates/application.hbs @@ -62,4 +62,5 @@ {{outlet}} - \ No newline at end of file + + diff --git a/addon/templates/orders/index/view.hbs b/addon/templates/orders/index/view.hbs index 201b1d9..2a854e1 100644 --- a/addon/templates/orders/index/view.hbs +++ b/addon/templates/orders/index/view.hbs @@ -1 +1,15 @@ - \ No newline at end of file + + + {{outlet}} + + diff --git a/addon/templates/orders/index/view/index.hbs b/addon/templates/orders/index/view/index.hbs new file mode 100644 index 0000000..1141962 --- /dev/null +++ b/addon/templates/orders/index/view/index.hbs @@ -0,0 +1,2 @@ + + diff --git a/addon/templates/orders/index/view/virtual.hbs b/addon/templates/orders/index/view/virtual.hbs new file mode 100644 index 0000000..6b03a13 --- /dev/null +++ b/addon/templates/orders/index/view/virtual.hbs @@ -0,0 +1,2 @@ +{{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}} + diff --git a/app/components/storefront/order/activity-list.js b/app/components/storefront/order/activity-list.js new file mode 100644 index 0000000..8a3f65e --- /dev/null +++ b/app/components/storefront/order/activity-list.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/activity-list'; diff --git a/app/components/storefront/order/activity-timeline.js b/app/components/storefront/order/activity-timeline.js new file mode 100644 index 0000000..d2e7ff1 --- /dev/null +++ b/app/components/storefront/order/activity-timeline.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/activity-timeline'; diff --git a/app/components/order-panel/details.js b/app/components/storefront/order/details.js similarity index 69% rename from app/components/order-panel/details.js rename to app/components/storefront/order/details.js index 52ede46..793b264 100644 --- a/app/components/order-panel/details.js +++ b/app/components/storefront/order/details.js @@ -1 +1 @@ -export { default } from '@fleetbase/storefront-engine/components/order-panel/details'; +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details'; diff --git a/app/components/storefront/order/details/activity.js b/app/components/storefront/order/details/activity.js new file mode 100644 index 0000000..498d8d1 --- /dev/null +++ b/app/components/storefront/order/details/activity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/activity'; diff --git a/app/components/storefront/order/details/comments.js b/app/components/storefront/order/details/comments.js new file mode 100644 index 0000000..432c91b --- /dev/null +++ b/app/components/storefront/order/details/comments.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/comments'; diff --git a/app/components/storefront/order/details/commerce-summary.js b/app/components/storefront/order/details/commerce-summary.js new file mode 100644 index 0000000..3a39870 --- /dev/null +++ b/app/components/storefront/order/details/commerce-summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/commerce-summary'; diff --git a/app/components/storefront/order/details/customer-insights.js b/app/components/storefront/order/details/customer-insights.js new file mode 100644 index 0000000..1a63c9a --- /dev/null +++ b/app/components/storefront/order/details/customer-insights.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/customer-insights'; diff --git a/app/components/storefront/order/details/customer.js b/app/components/storefront/order/details/customer.js new file mode 100644 index 0000000..c2a5a2f --- /dev/null +++ b/app/components/storefront/order/details/customer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/customer'; diff --git a/app/components/storefront/order/details/detail.js b/app/components/storefront/order/details/detail.js new file mode 100644 index 0000000..916fb0e --- /dev/null +++ b/app/components/storefront/order/details/detail.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/detail'; diff --git a/app/components/storefront/order/details/documents.js b/app/components/storefront/order/details/documents.js new file mode 100644 index 0000000..5ee2d64 --- /dev/null +++ b/app/components/storefront/order/details/documents.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/documents'; diff --git a/app/components/storefront/order/details/fulfillment.js b/app/components/storefront/order/details/fulfillment.js new file mode 100644 index 0000000..a0fabd6 --- /dev/null +++ b/app/components/storefront/order/details/fulfillment.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/fulfillment'; diff --git a/app/components/storefront/order/details/metadata.js b/app/components/storefront/order/details/metadata.js new file mode 100644 index 0000000..ea197db --- /dev/null +++ b/app/components/storefront/order/details/metadata.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/metadata'; diff --git a/app/components/storefront/order/details/order-breakdown.js b/app/components/storefront/order/details/order-breakdown.js new file mode 100644 index 0000000..66bb9f0 --- /dev/null +++ b/app/components/storefront/order/details/order-breakdown.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/order-breakdown'; diff --git a/app/components/storefront/order/details/registered-tab.js b/app/components/storefront/order/details/registered-tab.js new file mode 100644 index 0000000..22b95bd --- /dev/null +++ b/app/components/storefront/order/details/registered-tab.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/registered-tab'; diff --git a/app/components/storefront/order/details/route.js b/app/components/storefront/order/details/route.js new file mode 100644 index 0000000..63d210f --- /dev/null +++ b/app/components/storefront/order/details/route.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/route'; diff --git a/app/components/storefront/order/details/store.js b/app/components/storefront/order/details/store.js new file mode 100644 index 0000000..ce34ef3 --- /dev/null +++ b/app/components/storefront/order/details/store.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/store'; diff --git a/app/components/storefront/order/details/tracking.js b/app/components/storefront/order/details/tracking.js new file mode 100644 index 0000000..ba34925 --- /dev/null +++ b/app/components/storefront/order/details/tracking.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/details/tracking'; diff --git a/app/components/storefront/order/panel-header.js b/app/components/storefront/order/panel-header.js new file mode 100644 index 0000000..a068d89 --- /dev/null +++ b/app/components/storefront/order/panel-header.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/panel-header'; diff --git a/app/components/order-panel.js b/app/components/tracking-stop-progress.js similarity index 71% rename from app/components/order-panel.js rename to app/components/tracking-stop-progress.js index f33927a..e51c81f 100644 --- a/app/components/order-panel.js +++ b/app/components/tracking-stop-progress.js @@ -1 +1 @@ -export { default } from '@fleetbase/storefront-engine/components/order-panel'; +export { default } from '@fleetbase/storefront-engine/components/tracking-stop-progress'; diff --git a/app/controllers/orders/index/view/index.js b/app/controllers/orders/index/view/index.js new file mode 100644 index 0000000..a93277b --- /dev/null +++ b/app/controllers/orders/index/view/index.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/controllers/orders/index/view/index'; diff --git a/app/routes/orders/index/view/virtual.js b/app/routes/orders/index/view/virtual.js new file mode 100644 index 0000000..212fd3e --- /dev/null +++ b/app/routes/orders/index/view/virtual.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/routes/orders/index/view/virtual'; diff --git a/app/services/storefront-order-actions.js b/app/services/storefront-order-actions.js new file mode 100644 index 0000000..9852f53 --- /dev/null +++ b/app/services/storefront-order-actions.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/services/storefront-order-actions'; diff --git a/app/templates/orders/index/view/index.js b/app/templates/orders/index/view/index.js new file mode 100644 index 0000000..b546793 --- /dev/null +++ b/app/templates/orders/index/view/index.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/templates/orders/index/view/index'; diff --git a/app/templates/orders/index/view/virtual.js b/app/templates/orders/index/view/virtual.js new file mode 100644 index 0000000..cd3b7c5 --- /dev/null +++ b/app/templates/orders/index/view/virtual.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/templates/orders/index/view/virtual'; diff --git a/server/src/Http/Controllers/AnalyticsController.php b/server/src/Http/Controllers/AnalyticsController.php index 10b60af..17dbf7a 100644 --- a/server/src/Http/Controllers/AnalyticsController.php +++ b/server/src/Http/Controllers/AnalyticsController.php @@ -142,7 +142,7 @@ public function topProducts(Request $request) $products = []; $this->checkouts($companyUuid, $start, $end, $store)->get()->each(function ($checkout) use (&$products, $store) { - $items = data_get($checkout, 'cart_state.items', []); + $items = $this->cartStateItems($checkout->cart_state); foreach ($items as $item) { if ($store && data_get($item, 'store_id') !== $store->public_id) { continue; @@ -179,6 +179,36 @@ public function topProducts(Request $request) ]); } + public function cartStateItems($cartState): array + { + if ($cartState instanceof Collection) { + $cartState = $cartState->all(); + } + + if (is_object($cartState)) { + $cartState = (array) $cartState; + } + + if (!is_array($cartState)) { + return []; + } + + $items = data_get($cartState, 'items'); + if ($items instanceof Collection) { + return $items->all(); + } + + if (is_array($items)) { + return $items; + } + + if (is_object($items)) { + return (array) $items; + } + + return array_is_list($cartState) ? $cartState : []; + } + public function customerInsights(Request $request) { [$start, $end] = $this->dateRanges($request); diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index 2551969..caf2bca 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -5,9 +5,11 @@ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController as FleetbaseOrderController; use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Http\Resources\Index\Order as StorefrontOrderIndexResource; +use Fleetbase\Storefront\Http\Resources\Order as StorefrontOrderResource; use Fleetbase\Storefront\Notifications\StorefrontOrderAccepted; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -39,6 +41,42 @@ public function onQueryRecord(Builder $query): void $query->with(['customer', 'transaction', 'payload', 'driverAssigned', 'trackingNumber', 'trackingStatuses']); } + public function findRecord(Request $request, $id) + { + try { + $order = Order::findRecordOrFail($id, $this->detailRelations()); + } catch (ModelNotFoundException $exception) { + return response()->error('Order not found', 404); + } + + return [ + 'order' => new StorefrontOrderResource($order), + ]; + } + + private function detailRelations(): array + { + return [ + 'customer', + 'transaction', + 'payload', + 'payload.pickup', + 'payload.dropoff', + 'payload.return', + 'payload.waypoints', + 'payload.entities', + 'driverAssigned', + 'orderConfig', + 'trackingNumber', + 'trackingStatuses', + 'purchaseRate', + 'purchaseRate.serviceQuote', + 'purchaseRate.serviceQuote.items', + 'comments', + 'files', + ]; + } + /** * Accept an order by incrementing status to preparing. * diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php new file mode 100644 index 0000000..e752181 --- /dev/null +++ b/server/src/Http/Resources/Order.php @@ -0,0 +1,80 @@ +normalizeMeta(data_get($data, 'meta', [])); + + $data['customer_name'] = $this->customer_name; + $data['transaction_amount'] = $this->transaction_amount; + $data['meta'] = array_replace($meta, $this->storefrontOrderMeta()); + + if ($this->resource->relationLoaded('transaction') && $this->transaction) { + $data['transaction'] = [ + 'id' => $this->transaction->uuid, + 'uuid' => $this->transaction->uuid, + 'public_id' => $this->transaction->public_id ?? null, + 'amount' => $this->transaction->amount ?? $this->transaction_amount, + 'currency' => $this->transaction->currency ?? data_get($data, 'meta.currency'), + 'status' => $this->transaction->status ?? null, + 'gateway' => $this->transaction->gateway ?? data_get($data, 'meta.gateway'), + 'created_at' => $this->transaction->created_at, + 'updated_at' => $this->transaction->updated_at, + ]; + } + + return $data; + } + + private function storefrontOrderMeta(): array + { + $keys = [ + 'storefront', + 'storefront_id', + 'storefront_network', + 'storefront_network_id', + 'subtotal', + 'delivery_fee', + 'tip', + 'delivery_tip', + 'total', + 'currency', + 'gateway', + 'is_pickup', + 'is_master_order', + 'related_orders', + 'master_order_id', + 'checkout_id', + 'cart_id', + ]; + + $meta = array_intersect_key($this->normalizeMeta($this->resource->meta ?? []), array_flip($keys)); + + return $meta; + } + + private function normalizeMeta($meta): array + { + if ($meta instanceof Arrayable) { + $meta = $meta->toArray(); + } + + if (is_object($meta)) { + $meta = (array) $meta; + } + + return is_array($meta) ? $meta : []; + } +} diff --git a/server/tests/Feature.php b/server/tests/Feature.php index d3c1b05..312de45 100644 --- a/server/tests/Feature.php +++ b/server/tests/Feature.php @@ -2,8 +2,11 @@ use Fleetbase\FleetOps\Models\Contact; use Fleetbase\FleetOps\Models\Order as FleetOpsOrder; +use Fleetbase\FleetOps\Models\Payload; use Fleetbase\Models\Transaction; +use Fleetbase\Storefront\Http\Controllers\AnalyticsController; use Fleetbase\Storefront\Http\Resources\Index\Order as StorefrontOrderIndexResource; +use Fleetbase\Storefront\Http\Resources\Order as StorefrontOrderResource; test('storefront order index resource includes checkout meta and storefront display fields', function () { $order = new FleetOpsOrder(); @@ -44,3 +47,92 @@ ]) ->and($data['meta'])->not->toHaveKey('unrelated'); }); + +test('analytics top products extracts items from wrapped and raw cart state snapshots', function () { + $controller = new AnalyticsController(); + $items = [ + [ + 'product_id' => 'product_1', + 'store_id' => 'store_1', + 'quantity' => 2, + 'subtotal' => 50, + ], + ]; + + expect($controller->cartStateItems(['items' => $items]))->toBe($items) + ->and($controller->cartStateItems($items))->toBe($items) + ->and($controller->cartStateItems((object) ['items' => $items]))->toBe($items) + ->and($controller->cartStateItems(['subtotal' => 50]))->toBe([]); +}); + +test('storefront order detail resource includes checkout totals and transaction context', function () { + $order = new FleetOpsOrder(); + $order->forceFill([ + 'uuid' => 'order_uuid', + 'public_id' => 'order_123', + 'internal_id' => '1001', + 'status' => 'created', + 'meta' => [ + 'storefront' => [ + 'id' => 'store_uuid', + 'public_id' => 'store_123', + 'name' => 'Tasty Store', + 'logo_url' => 'https://example.com/store.png', + 'is_store' => true, + 'is_network' => false, + 'extra' => 'hidden', + ], + 'subtotal' => 42.25, + 'total' => 50, + 'currency' => 'USD', + 'gateway' => 'cash', + 'is_pickup' => true, + 'unrelated' => 'hidden', + ], + ]); + + $customer = new Contact(); + $customer->forceFill(['uuid' => 'contact_uuid', 'name' => 'Ada Lovelace']); + + $transaction = new Transaction(); + $transaction->forceFill(['uuid' => 'transaction_uuid', 'amount' => 50, 'currency' => 'USD', 'status' => 'paid']); + + $payload = new Payload(); + $payload->forceFill(['uuid' => 'payload_uuid']); + $payload->setRelation('entities', collect()); + + $order->setRelation('customer', $customer); + $order->setRelation('transaction', $transaction); + $order->setRelation('payload', $payload); + $order->setRelation('trackingStatuses', collect()); + $order->setRelation('comments', collect()); + $order->setRelation('files', collect()); + + $data = (new StorefrontOrderResource($order))->toArray(request()); + + expect($data)->toBeArray() + ->and($data['customer_name'])->toBe('Ada Lovelace') + ->and($data['transaction_amount'])->toBe($order->transaction_amount) + ->and($data['transaction'])->toMatchArray([ + 'id' => 'transaction_uuid', + 'amount' => 50, + 'currency' => 'USD', + 'status' => 'paid', + ]) + ->and($data['meta'])->toMatchArray([ + 'subtotal' => 42.25, + 'total' => 50, + 'currency' => 'USD', + 'gateway' => 'cash', + 'is_pickup' => true, + 'storefront' => [ + 'id' => 'store_uuid', + 'public_id' => 'store_123', + 'name' => 'Tasty Store', + 'logo_url' => 'https://example.com/store.png', + 'is_store' => true, + 'is_network' => false, + ], + ]) + ->and($data['meta']['storefront'])->not->toHaveKey('extra'); +}); diff --git a/tests/integration/components/order-panel-test.js b/tests/integration/components/order-panel-test.js deleted file mode 100644 index eff6352..0000000 --- a/tests/integration/components/order-panel-test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -module('Integration | Component | order-panel', function (hooks) { - setupRenderingTest(hooks); - - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - - await render(hbs``); - - assert.dom().hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom().hasText('template block text'); - }); -}); diff --git a/tests/integration/components/order-panel/details-test.js b/tests/integration/components/order-panel/details-test.js deleted file mode 100644 index e5c292c..0000000 --- a/tests/integration/components/order-panel/details-test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -module('Integration | Component | order-panel/details', function (hooks) { - setupRenderingTest(hooks); - - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - - await render(hbs``); - - assert.dom().hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom().hasText('template block text'); - }); -}); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js new file mode 100644 index 0000000..1d392fc --- /dev/null +++ b/tests/integration/components/storefront/order/details-test.js @@ -0,0 +1,66 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | storefront/order/details', function (hooks) { + setupRenderingTest(hooks); + + test('it renders dedicated storefront order detail panels', async function (assert) { + this.set('order', { + public_id: 'order_test', + internal_id: '1001', + status: 'created', + createdAt: 'May 29, 2026 18:00', + scheduledAt: null, + transaction_amount: 100, + meta: { + storefront: { + name: 'Tasty Store', + logo_url: 'https://example.com/store.png', + }, + subtotal: 90, + delivery_fee: 10, + total: 100, + currency: 'USD', + gateway: 'cash', + is_pickup: true, + }, + customer: {}, + driver_assigned: {}, + payload: { + pickup: {}, + dropoff: {}, + entities: [ + { + name: 'Burger', + description: 'Cheeseburger', + image_url: 'https://example.com/burger.png', + meta: { + quantity: 2, + subtotal: 20, + }, + }, + ], + }, + tracking_number: {}, + tracking_statuses: [], + files: [], + }); + + await render(hbs``); + + assert.dom('.next-content-panel-title-container').hasTextContaining('Activity'); + assert.dom('.next-content-panel-title-container').hasTextContaining('Store'); + assert.dom('.next-content-panel-title-container').hasTextContaining('Order'); + assert.dom('.next-content-panel-title-container').hasTextContaining('Details'); + assert.dom('.next-content-panel-title-container').hasTextContaining('Customer Insights'); + assert.dom('.next-content-panel-title-container').hasTextContaining('Route'); + assert.dom('.next-content-panel-title-container').hasTextContaining('Metadata'); + assert.dom('.storefront-order-person__name').hasTextContaining('Tasty Store'); + assert.dom('.storefront-order-item__image').exists(); + assert.dom('.storefront-order-item__price').exists(); + assert.dom().doesNotContainText('Delivery Address'); + assert.dom().doesNotContainText('Pickup Address'); + }); +}); diff --git a/tests/unit/routes/orders/index/view-test.js b/tests/unit/routes/orders/index/view-test.js index d12b26a..6fc4f0f 100644 --- a/tests/unit/routes/orders/index/view-test.js +++ b/tests/unit/routes/orders/index/view-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Route | orders/index/view', function (hooks) { setupTest(hooks); @@ -8,4 +9,58 @@ module('Unit | Route | orders/index/view', function (hooks) { let route = this.owner.lookup('route:orders/index/view'); assert.ok(route); }); + + test('it loads order details from the Storefront internal namespace', async function (assert) { + assert.expect(5); + + class FetchStub extends Service { + get(path, params, options) { + assert.strictEqual(path, 'orders/order_test'); + assert.deepEqual(params, { storefront: 'store_test' }); + assert.deepEqual(options, { + namespace: 'storefront/int/v1', + normalizeToEmberData: true, + normalizeModelType: 'order', + }); + + return Promise.resolve({ id: 'order_test' }); + } + } + + class StorefrontStub extends Service { + getActiveStore(key) { + assert.strictEqual(key, 'public_id'); + return 'store_test'; + } + } + + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:storefront', StorefrontStub); + + const route = this.owner.lookup('route:orders/index/view'); + const order = await route.model({ public_id: 'order_test' }); + + assert.deepEqual(order, { id: 'order_test' }); + }); + + test('it exposes overview and registered order detail tabs', function (assert) { + assert.expect(5); + + class MenuServiceStub extends Service { + getMenuItems(registry) { + assert.strictEqual(registry, 'storefront:component:order:details'); + return [{ route: 'orders.index.view.virtual', label: 'Invoice', slug: 'invoice', icon: 'file-invoice-dollar' }]; + } + } + + this.owner.register('service:universe/menu-service', MenuServiceStub); + + const controller = this.owner.lookup('controller:orders/index/view'); + const tabs = controller.tabs; + + assert.strictEqual(tabs.length, 2); + assert.strictEqual(tabs[0].label, 'Overview'); + assert.strictEqual(tabs[0].route, 'orders.index.view.index'); + assert.strictEqual(tabs[1].label, 'Invoice'); + }); }); diff --git a/tests/unit/services/order-actions-test.js b/tests/unit/services/order-actions-test.js index de91b25..7ebe77b 100644 --- a/tests/unit/services/order-actions-test.js +++ b/tests/unit/services/order-actions-test.js @@ -9,4 +9,11 @@ module('Unit | Service | order-actions', function (hooks) { let service = this.owner.lookup('service:order-actions'); assert.ok(service); }); + + test('it remains a compatibility alias for storefront order actions', function (assert) { + let service = this.owner.lookup('service:order-actions'); + + assert.strictEqual(typeof service.viewOrder, 'function'); + assert.strictEqual(typeof service.actionButtonsFor, 'function'); + }); }); diff --git a/tests/unit/services/storefront-order-actions-test.js b/tests/unit/services/storefront-order-actions-test.js new file mode 100644 index 0000000..9e3711b --- /dev/null +++ b/tests/unit/services/storefront-order-actions-test.js @@ -0,0 +1,65 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +module('Unit | Service | storefront-order-actions', function (hooks) { + setupTest(hooks); + + test('it opens order details through the resource context panel', async function (assert) { + assert.expect(14); + + class FetchStub extends Service { + get(path, params, options) { + assert.strictEqual(path, 'orders/order_test'); + assert.deepEqual(params, { storefront: 'store_test' }); + assert.deepEqual(options, { + namespace: 'storefront/int/v1', + normalizeToEmberData: true, + normalizeModelType: 'order', + }); + + return Promise.resolve({ + id: 'order_test', + public_id: 'order_test', + }); + } + } + + class StorefrontStub extends Service { + getActiveStore(key) { + assert.strictEqual(key, 'public_id'); + return 'store_test'; + } + } + + class ResourceContextPanelStub extends Service { + open(definition) { + assert.strictEqual(definition.resource.public_id, 'order_test'); + assert.strictEqual(definition.header, 'storefront/order/panel-header'); + assert.notOk(definition.content); + assert.strictEqual(definition.tabs.length, 2); + assert.strictEqual(definition.tabs[0].label, 'Overview'); + assert.strictEqual(definition.tabs[0].component, 'storefront/order/details'); + assert.strictEqual(definition.tabs[1].key, 'invoice'); + assert.strictEqual(definition.tabs[1].component, 'storefront/order/details/registered-tab'); + assert.strictEqual(definition.width, '560px'); + } + } + + class MenuServiceStub extends Service { + getMenuItems(registry) { + assert.strictEqual(registry, 'storefront:component:order:details'); + return [{ title: 'Invoice', slug: 'invoice', icon: 'file-invoice-dollar' }]; + } + } + + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:storefront', StorefrontStub); + this.owner.register('service:resource-context-panel', ResourceContextPanelStub); + this.owner.register('service:universe/menu-service', MenuServiceStub); + + const service = this.owner.lookup('service:storefront-order-actions'); + + await service.viewOrder({ public_id: 'order_test' }); + }); +}); From 1898853e032a168ae3459230b5fe5b6953ea45c5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 4 Jun 2026 21:04:50 +0800 Subject: [PATCH 11/14] latest --- addon/components/customer-panel/orders.hbs | 58 +- addon/components/customer-panel/orders.js | 4 + addon/components/modals/assign-driver.hbs | 11 +- .../modals/create-product-category.hbs | 44 +- addon/components/modals/manage-addons.hbs | 73 +- addon/components/modals/manage-addons.js | 109 +- .../modals/order-ready-assign-driver.hbs | 4 +- .../modals/order-ready-assign-driver.js | 21 + .../modals/product-image-preview.hbs | 10 + .../modals/product-image-preview.js | 3 + addon/components/storefront/order/actions.hbs | 13 + addon/components/storefront/order/actions.js | 18 + .../storefront/order/details/activity.js | 22 +- .../storefront/order/details/detail.hbs | 4 + .../storefront/order/details/detail.js | 4 + .../storefront/product/addon-management.hbs | 81 ++ .../storefront/product/addon-management.js | 96 ++ addon/components/storefront/product/card.hbs | 117 ++ addon/components/storefront/product/card.js | 59 + .../storefront/product/category-form.hbs | 33 + .../storefront/product/category-sidebar.hbs | 52 + .../storefront/product/category-sidebar.js | 7 + .../storefront/product/collection.hbs | 44 + .../storefront/product/collection.js | 19 + addon/components/storefront/product/form.hbs | 50 + .../storefront/product/form/addons.hbs | 58 + .../storefront/product/form/availability.hbs | 9 + .../storefront/product/form/basics.hbs | 52 + .../storefront/product/form/flags.hbs | 35 + .../storefront/product/form/media.hbs | 75 + .../storefront/product/form/metadata.hbs | 3 + .../storefront/product/form/pricing.hbs | 24 + .../storefront/product/form/translations.hbs | 8 + .../storefront/product/form/variants.hbs | 78 ++ .../storefront/product/form/videos.hbs | 8 + addon/components/widget/customer-insights.js | 6 +- addon/components/widget/customers.hbs | 6 +- addon/components/widget/kpi-active-orders.hbs | 2 +- addon/components/widget/kpi-aov.hbs | 2 +- .../widget/kpi-cancellation-rate.hbs | 2 +- .../components/widget/kpi-cart-conversion.hbs | 2 +- .../widget/kpi-completed-orders.hbs | 2 +- addon/components/widget/kpi-customers.hbs | 2 +- addon/components/widget/kpi-orders.hbs | 2 +- addon/components/widget/kpi-revenue.hbs | 2 +- addon/components/widget/orders-by-status.js | 6 +- addon/components/widget/orders.hbs | 113 +- addon/components/widget/orders.js | 8 +- addon/components/widget/revenue-trend.hbs | 7 +- addon/components/widget/revenue-trend.js | 107 +- .../widget/storefront-key-metrics.js | 8 +- .../{kpi-tile.hbs => storefront-kpi-tile.hbs} | 4 +- .../{kpi-tile.js => storefront-kpi-tile.js} | 8 +- .../components/widget/storefront-metrics.hbs | 12 +- addon/components/widget/storefront-metrics.js | 118 +- addon/components/widget/top-products.hbs | 8 +- addon/components/widget/top-products.js | 6 +- addon/controllers/home.js | 5 +- addon/controllers/orders/index.js | 10 +- addon/controllers/products/index.js | 39 + addon/controllers/products/index/category.js | 20 + addon/controllers/products/index/index.js | 16 +- addon/routes/orders/index/view.js | 35 + addon/services/order-actions.js | 3 - addon/services/storefront-dashboard.js | 133 ++ addon/services/storefront-order-actions.js | 375 ++++- addon/services/storefront-order-workflow.js | 159 +++ addon/services/storefront.js | 12 +- addon/styles/storefront-engine.css | 1204 ++++++++++++++++- addon/templates/home.hbs | 14 +- addon/templates/orders/index.hbs | 4 +- addon/templates/products/index.hbs | 128 +- addon/templates/products/index/category.hbs | 64 +- .../products/index/category/edit.hbs | 2 +- .../templates/products/index/category/new.hbs | 399 +----- addon/templates/products/index/index.hbs | 47 +- addon/templates/products/index/index/edit.hbs | 2 +- addon/utils/commerce-date-ranges.js | 171 +-- .../modals/product-image-preview.js | 1 + app/components/storefront/order/actions.js | 1 + .../storefront/product/addon-management.js | 1 + .../product/card.js} | 2 +- .../storefront/product/category-form.js | 1 + .../storefront/product/category-sidebar.js | 1 + .../storefront/product/collection.js | 1 + app/components/storefront/product/form.js | 1 + .../storefront/product/form/addons.js | 1 + .../storefront/product/form/availability.js | 1 + .../storefront/product/form/basics.js | 1 + .../storefront/product/form/flags.js | 1 + .../storefront/product/form/media.js | 1 + .../storefront/product/form/metadata.js | 1 + .../storefront/product/form/pricing.js | 1 + .../storefront/product/form/translations.js | 1 + .../storefront/product/form/variants.js | 1 + .../storefront/product/form/videos.js | 1 + app/components/widget/storefront-kpi-tile.js | 1 + app/services/order-actions.js | 1 - app/services/storefront-dashboard.js | 1 + app/services/storefront-order-workflow.js | 1 + .../Testing/CatalogAndProductsSeeder.php | 2 +- .../seeders/Testing/CheckoutOrdersSeeder.php | 22 + .../Http/Controllers/AnalyticsController.php | 9 +- .../src/Http/Controllers/OrderController.php | 78 +- server/src/Models/Product.php | 1 + server/src/routes.php | 2 + server/tests/Feature.php | 12 + .../modals/create-product-category-test.js | 26 +- .../components/modals/manage-addons-test.js | 33 +- .../storefront/product/card-test.js | 76 ++ .../product/category-sidebar-test.js | 44 + .../components/widget/revenue-trend-test.js | 105 +- ...le-test.js => storefront-kpi-tile-test.js} | 8 +- .../components/widget/top-products-test.js | 3 +- tests/unit/services/order-actions-test.js | 19 - .../services/storefront-dashboard-test.js | 51 + .../services/storefront-order-actions-test.js | 113 ++ .../storefront-order-workflow-test.js | 55 + tests/unit/utils/commerce-date-ranges-test.js | 24 +- translations/ar-ae.yaml | 4 + translations/bg-bg.yaml | 4 + translations/en-us.yaml | 4 + translations/es-es.yaml | 4 + translations/fr-fr.yaml | 4 + translations/mn-mn.yaml | 4 + translations/pt-br.yaml | 4 + translations/ru-ru.yaml | 4 + translations/vi-vn.yaml | 4 + translations/zh-cn.yaml | 4 + 129 files changed, 3898 insertions(+), 1429 deletions(-) create mode 100644 addon/components/modals/order-ready-assign-driver.js create mode 100644 addon/components/modals/product-image-preview.hbs create mode 100644 addon/components/modals/product-image-preview.js create mode 100644 addon/components/storefront/order/actions.hbs create mode 100644 addon/components/storefront/order/actions.js create mode 100644 addon/components/storefront/product/addon-management.hbs create mode 100644 addon/components/storefront/product/addon-management.js create mode 100644 addon/components/storefront/product/card.hbs create mode 100644 addon/components/storefront/product/card.js create mode 100644 addon/components/storefront/product/category-form.hbs create mode 100644 addon/components/storefront/product/category-sidebar.hbs create mode 100644 addon/components/storefront/product/category-sidebar.js create mode 100644 addon/components/storefront/product/collection.hbs create mode 100644 addon/components/storefront/product/collection.js create mode 100644 addon/components/storefront/product/form.hbs create mode 100644 addon/components/storefront/product/form/addons.hbs create mode 100644 addon/components/storefront/product/form/availability.hbs create mode 100644 addon/components/storefront/product/form/basics.hbs create mode 100644 addon/components/storefront/product/form/flags.hbs create mode 100644 addon/components/storefront/product/form/media.hbs create mode 100644 addon/components/storefront/product/form/metadata.hbs create mode 100644 addon/components/storefront/product/form/pricing.hbs create mode 100644 addon/components/storefront/product/form/translations.hbs create mode 100644 addon/components/storefront/product/form/variants.hbs create mode 100644 addon/components/storefront/product/form/videos.hbs rename addon/components/widget/{kpi-tile.hbs => storefront-kpi-tile.hbs} (82%) rename addon/components/widget/{kpi-tile.js => storefront-kpi-tile.js} (88%) delete mode 100644 addon/services/order-actions.js create mode 100644 addon/services/storefront-dashboard.js create mode 100644 addon/services/storefront-order-workflow.js create mode 100644 app/components/modals/product-image-preview.js create mode 100644 app/components/storefront/order/actions.js create mode 100644 app/components/storefront/product/addon-management.js rename app/components/{widget/kpi-tile.js => storefront/product/card.js} (70%) create mode 100644 app/components/storefront/product/category-form.js create mode 100644 app/components/storefront/product/category-sidebar.js create mode 100644 app/components/storefront/product/collection.js create mode 100644 app/components/storefront/product/form.js create mode 100644 app/components/storefront/product/form/addons.js create mode 100644 app/components/storefront/product/form/availability.js create mode 100644 app/components/storefront/product/form/basics.js create mode 100644 app/components/storefront/product/form/flags.js create mode 100644 app/components/storefront/product/form/media.js create mode 100644 app/components/storefront/product/form/metadata.js create mode 100644 app/components/storefront/product/form/pricing.js create mode 100644 app/components/storefront/product/form/translations.js create mode 100644 app/components/storefront/product/form/variants.js create mode 100644 app/components/storefront/product/form/videos.js create mode 100644 app/components/widget/storefront-kpi-tile.js delete mode 100644 app/services/order-actions.js create mode 100644 app/services/storefront-dashboard.js create mode 100644 app/services/storefront-order-workflow.js create mode 100644 tests/integration/components/storefront/product/card-test.js create mode 100644 tests/integration/components/storefront/product/category-sidebar-test.js rename tests/integration/components/widget/{kpi-tile-test.js => storefront-kpi-tile-test.js} (85%) delete mode 100644 tests/unit/services/order-actions-test.js create mode 100644 tests/unit/services/storefront-dashboard-test.js create mode 100644 tests/unit/services/storefront-order-workflow-test.js 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 795d3f4..2f31e96 100644 --- a/addon/components/customer-panel/orders.js +++ b/addon/components/customer-panel/orders.js @@ -56,6 +56,10 @@ export default class CustomerPanelOrdersComponent extends Component { }); } + @action handleOrderChange() { + this.loadOrders.perform(); + } + @action async acceptOrder(order) { await this.storefrontOrderActions.acceptOrder(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/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/details/activity.js b/addon/components/storefront/order/details/activity.js index 4e687a4..69e934b 100644 --- a/addon/components/storefront/order/details/activity.js +++ b/addon/components/storefront/order/details/activity.js @@ -6,7 +6,10 @@ import { task } from 'ember-concurrency'; export default class StorefrontOrderDetailsActivityComponent extends Component { @service appCache; @service notifications; - @tracked layout = this.appCache.get('storefront:order:activity:layout', 'timeline'); + @service storefrontOrderActions; + @service storefrontOrderWorkflow; + activityLayoutCacheKey = 'storefront:order:activity:layout:v2'; + @tracked layout = this.appCache.get(this.activityLayoutCacheKey, 'list'); constructor() { super(...arguments); @@ -24,11 +27,26 @@ export default class StorefrontOrderDetailsActivityComponent extends Component { 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', @@ -41,7 +59,7 @@ export default class StorefrontOrderDetailsActivityComponent extends Component { icon: this.layout === 'timeline' ? 'list' : 'timeline', onClick: () => { this.layout = this.layout === 'timeline' ? 'list' : 'timeline'; - this.appCache.set('storefront:order:activity:layout', this.layout); + this.appCache.set(this.activityLayoutCacheKey, this.layout); }, }, ], diff --git a/addon/components/storefront/order/details/detail.hbs b/addon/components/storefront/order/details/detail.hbs index a2121f7..ca92fad 100644 --- a/addon/components/storefront/order/details/detail.hbs +++ b/addon/components/storefront/order/details/detail.hbs @@ -41,6 +41,10 @@
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)}}
diff --git a/addon/components/storefront/order/details/detail.js b/addon/components/storefront/order/details/detail.js index debce9e..3e40ecc 100644 --- a/addon/components/storefront/order/details/detail.js +++ b/addon/components/storefront/order/details/detail.js @@ -1,6 +1,10 @@ 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); } 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 @@ + + + +