+
{{outlet}}
-
\ No newline at end of file
+
diff --git a/addon/templates/products/index/category.hbs b/addon/templates/products/index/category.hbs
index cac7441..dc01369 100644
--- a/addon/templates/products/index/category.hbs
+++ b/addon/templates/products/index/category.hbs
@@ -1,55 +1,11 @@
-
- {{#if this.category}}
-
-
-
- {{/if}}
-
+
-
-
-
-
-
-
-{{outlet}}
\ No newline at end of file
+{{outlet}}
diff --git a/addon/templates/products/index/category/edit.hbs b/addon/templates/products/index/category/edit.hbs
index e2147ca..c24cd68 100644
--- a/addon/templates/products/index/category/edit.hbs
+++ b/addon/templates/products/index/category/edit.hbs
@@ -1 +1 @@
-{{outlet}}
\ No newline at end of file
+{{outlet}}
diff --git a/addon/templates/products/index/category/new.hbs b/addon/templates/products/index/category/new.hbs
index c9624c9..0b85068 100644
--- a/addon/templates/products/index/category/new.hbs
+++ b/addon/templates/products/index/category/new.hbs
@@ -1,4 +1,4 @@
-
+
- {{#let (cannot this.abilityPermission) as |unauthorized|}}
-
-
-
-
-
-
-
-
-
- {{model.name}}
-
-
-
-
-
-
-
-
-
- {{tag}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This is a service
-
-
-
- {{#if this.product.is_service}}
-
-
- This service is bookable
-
-
- {{/if}}
-
-
-
- This product is on sale
-
-
-
-
-
- This product is recommended
-
-
-
-
-
- This product is available
-
-
-
-
-
-
- <:actions>
-
-
- <:default as |activeTab|>
- {{#let activeTab.variant as |variant|}}
-
-
-
-
-
-
-
- {{#each variant.options as |variantOption index|}}
-
- {{/each}}
-
-
- {{/let}}
-
-
-
-
-
-
-
-
-
- {{#each this.product.addon_categories as |addonCategory index|}}
-
-
-
-
-
- {{addonCategory.name}}
-
-
-
-
-
- {{#each addonCategory.category.addons as |addon|}}
-
-
-
-
-
- {{addon.name}}
-
-
- {{addon.description}}
-
-
- {{format-currency addon.price this.product.currency}}
-
-
- {{/each}}
-
-
- {{/each}}
-
-
-
-
-
-
-
-
-
- {{#if this.isUploading}}
-
- {{else}}
- {{#let (file-queue name="files" onFileAdded=this.queueFile accept=(join "," this.acceptedFileTypes)) as |queue|}}
-
- {{#if dropzone.active}}
- {{#if dropzone.valid}}
- {{t "component.dropzone.drop-to-upload"}}
- {{else}}
- {{t "component.dropzone.invalid"}}
- {{/if}}
- {{else if queue.files.length}}
-
-
- {{t "component.dropzone.files-ready-for-upload" numOfFiles=(pluralize queue.files.length (t "component.dropzone.file"))}}
-
- ({{queue.progress}}%)
- {{else}}
-
-
- {{t "component.dropzone.upload-images-videos"}}
-
-
- {{/if}}
-
- {{/let}}
- {{#if this.uploadQueue}}
-
-
- {{t "component.dropzone.upload-queue"}}
-
-
- {{#each this.uploadQueue as |file|}}
-
-
{{truncate-filename file.name 50}}
-
-
- {{round file.progress}}%
-
-
- {{/each}}
-
-
- {{/if}}
-
-
- {{#each this.product.files as |file|}}
-
-
-
-
-
- {{/each}}
-
-
- {{/if}}
-
-
-
-
- Add YouTube video urls to assosciate with this product or service.
-
-
-
- {{/let}}
+
+
-
\ No newline at end of file
+
diff --git a/addon/templates/products/index/index.hbs b/addon/templates/products/index/index.hbs
index 0064998..9899ef3 100644
--- a/addon/templates/products/index/index.hbs
+++ b/addon/templates/products/index/index.hbs
@@ -1,39 +1,10 @@
-
+
-
-
- {{#each @model as |product|}}
-
-
-
-
-
-
{{product.name}}
-
{{product.description}}
-
{{format-currency product.price product.currency}}
-
-
-
- {{else}}
-
-
No products
-
- {{/each}}
-
-
-
-
-
-
-
-
-{{outlet}}
\ No newline at end of file
+{{outlet}}
diff --git a/addon/templates/products/index/index/edit.hbs b/addon/templates/products/index/index/edit.hbs
index e2147ca..c24cd68 100644
--- a/addon/templates/products/index/index/edit.hbs
+++ b/addon/templates/products/index/index/edit.hbs
@@ -1 +1 @@
-{{outlet}}
\ No newline at end of file
+{{outlet}}
diff --git a/addon/utils/commerce-date-ranges.js b/addon/utils/commerce-date-ranges.js
index 161648d..37ec282 100644
--- a/addon/utils/commerce-date-ranges.js
+++ b/addon/utils/commerce-date-ranges.js
@@ -1,42 +1,36 @@
-import {
- startOfDay,
- endOfDay,
- startOfWeek,
- endOfWeek,
- startOfMonth,
- endOfMonth,
- startOfQuarter,
- endOfQuarter,
- startOfYear,
- endOfYear,
- subDays,
- subWeeks,
- subMonths,
- subQuarters,
- subYears,
- format,
-} from 'date-fns';
+import { startOfDay, endOfDay, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, startOfYear, endOfYear, addDays, subDays, subMonths, subQuarters, subYears, format } from 'date-fns';
+
+const currentYear = new Date().getFullYear();
+
+function getCurrentYearQuarterRange(quarter) {
+ const start = new Date(currentYear, (quarter - 1) * 3, 1);
+
+ return [startOfQuarter(start), endOfQuarter(start)];
+}
+
+function getBlackFriday(year = currentYear) {
+ let fridayCount = 0;
+ let date = new Date(year, 10, 1);
+
+ while (date.getMonth() === 10) {
+ if (date.getDay() === 5) {
+ fridayCount++;
+
+ if (fridayCount === 4) {
+ return date;
+ }
+ }
+
+ date = addDays(date, 1);
+ }
+}
/**
* Predefined date range buttons for ecommerce analytics dashboard
* Each button contains a label and a function that returns [startDate, endDate]
*/
export const predefinedDateRanges = [
- // Recent periods - most commonly used for daily monitoring
- {
- label: 'Today',
- getValue: () => {
- const today = new Date();
- return [startOfDay(today), endOfDay(today)];
- },
- },
- {
- label: 'Yesterday',
- getValue: () => {
- const yesterday = subDays(new Date(), 1);
- return [startOfDay(yesterday), endOfDay(yesterday)];
- },
- },
+ // Rolling periods used most often for commerce dashboards.
{
label: 'Last 7 Days',
getValue: () => {
@@ -45,14 +39,6 @@ export const predefinedDateRanges = [
return [startOfDay(sevenDaysAgo), endOfDay(today)];
},
},
- {
- label: 'Last 14 Days',
- getValue: () => {
- const today = new Date();
- const fourteenDaysAgo = subDays(today, 13);
- return [startOfDay(fourteenDaysAgo), endOfDay(today)];
- },
- },
{
label: 'Last 30 Days',
getValue: () => {
@@ -61,24 +47,16 @@ export const predefinedDateRanges = [
return [startOfDay(thirtyDaysAgo), endOfDay(today)];
},
},
-
- // Weekly periods
{
- label: 'This Week',
+ label: 'Last 90 Days',
getValue: () => {
const today = new Date();
- return [startOfWeek(today, { weekStartsOn: 1 }), endOfWeek(today, { weekStartsOn: 1 })]; // Monday start
- },
- },
- {
- label: 'Last Week',
- getValue: () => {
- const lastWeek = subWeeks(new Date(), 1);
- return [startOfWeek(lastWeek, { weekStartsOn: 1 }), endOfWeek(lastWeek, { weekStartsOn: 1 })];
+ const ninetyDaysAgo = subDays(today, 89);
+ return [startOfDay(ninetyDaysAgo), endOfDay(today)];
},
},
- // Monthly periods - crucial for monthly reporting
+ // Month and quarter reporting.
{
label: 'This Month',
getValue: () => {
@@ -93,24 +71,6 @@ export const predefinedDateRanges = [
return [startOfMonth(lastMonth), endOfMonth(lastMonth)];
},
},
- {
- label: 'Last 3 Months',
- getValue: () => {
- const today = new Date();
- const threeMonthsAgo = subMonths(today, 3);
- return [startOfMonth(threeMonthsAgo), endOfMonth(today)];
- },
- },
- {
- label: 'Last 6 Months',
- getValue: () => {
- const today = new Date();
- const sixMonthsAgo = subMonths(today, 6);
- return [startOfMonth(sixMonthsAgo), endOfMonth(today)];
- },
- },
-
- // Quarterly periods - important for business reporting
{
label: 'This Quarter',
getValue: () => {
@@ -126,35 +86,23 @@ export const predefinedDateRanges = [
},
},
{
- label: 'Q1 2024',
- getValue: () => {
- const q1Start = new Date(2024, 0, 1); // January 1, 2024
- return [startOfQuarter(q1Start), endOfQuarter(q1Start)];
- },
+ label: `Q1 ${currentYear}`,
+ getValue: () => getCurrentYearQuarterRange(1),
},
{
- label: 'Q2 2024',
- getValue: () => {
- const q2Start = new Date(2024, 3, 1); // April 1, 2024
- return [startOfQuarter(q2Start), endOfQuarter(q2Start)];
- },
+ label: `Q2 ${currentYear}`,
+ getValue: () => getCurrentYearQuarterRange(2),
},
{
- label: 'Q3 2024',
- getValue: () => {
- const q3Start = new Date(2024, 6, 1); // July 1, 2024
- return [startOfQuarter(q3Start), endOfQuarter(q3Start)];
- },
+ label: `Q3 ${currentYear}`,
+ getValue: () => getCurrentYearQuarterRange(3),
},
{
- label: 'Q4 2024',
- getValue: () => {
- const q4Start = new Date(2024, 9, 1); // October 1, 2024
- return [startOfQuarter(q4Start), endOfQuarter(q4Start)];
- },
+ label: `Q4 ${currentYear}`,
+ getValue: () => getCurrentYearQuarterRange(4),
},
- // Yearly periods - essential for annual analysis
+ // Annual reporting.
{
label: 'This Year',
getValue: () => {
@@ -169,47 +117,22 @@ export const predefinedDateRanges = [
return [startOfYear(lastYear), endOfYear(lastYear)];
},
},
- {
- label: '2024',
- getValue: () => {
- const year2024 = new Date(2024, 0, 1);
- return [startOfYear(year2024), endOfYear(year2024)];
- },
- },
- {
- label: '2023',
- getValue: () => {
- const year2023 = new Date(2023, 0, 1);
- return [startOfYear(year2023), endOfYear(year2023)];
- },
- },
- // Special ecommerce periods
+ // Current-year ecommerce seasons.
{
label: 'Black Friday Week',
getValue: () => {
- // Assuming Black Friday 2024 is November 29th
- const blackFriday = new Date(2024, 10, 29); // November 29, 2024
- const weekStart = subDays(blackFriday, 3); // Tuesday before
- const weekEnd = subDays(blackFriday, -3); // Monday after
+ const blackFriday = getBlackFriday();
+ const weekStart = subDays(blackFriday, 3);
+ const weekEnd = addDays(blackFriday, 3);
return [startOfDay(weekStart), endOfDay(weekEnd)];
},
},
{
- label: 'Holiday Season 2024',
- getValue: () => {
- // November 1st to December 31st
- const seasonStart = new Date(2024, 10, 1); // November 1, 2024
- const seasonEnd = new Date(2024, 11, 31); // December 31, 2024
- return [startOfDay(seasonStart), endOfDay(seasonEnd)];
- },
- },
- {
- label: 'Back to School 2024',
+ label: 'Holiday Season',
getValue: () => {
- // August 1st to September 15th
- const seasonStart = new Date(2024, 7, 1); // August 1, 2024
- const seasonEnd = new Date(2024, 8, 15); // September 15, 2024
+ const seasonStart = new Date(currentYear, 10, 1);
+ const seasonEnd = new Date(currentYear, 11, 31);
return [startOfDay(seasonStart), endOfDay(seasonEnd)];
},
},
diff --git a/app/components/modals/product-image-preview.js b/app/components/modals/product-image-preview.js
new file mode 100644
index 0000000..7830ba6
--- /dev/null
+++ b/app/components/modals/product-image-preview.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/modals/product-image-preview';
diff --git a/app/components/storefront/order/actions.js b/app/components/storefront/order/actions.js
new file mode 100644
index 0000000..3639c17
--- /dev/null
+++ b/app/components/storefront/order/actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/order/actions';
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/storefront/order/details.js b/app/components/storefront/order/details.js
new file mode 100644
index 0000000..793b264
--- /dev/null
+++ b/app/components/storefront/order/details.js
@@ -0,0 +1 @@
+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/storefront/product/addon-management.js b/app/components/storefront/product/addon-management.js
new file mode 100644
index 0000000..53ea209
--- /dev/null
+++ b/app/components/storefront/product/addon-management.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/addon-management';
diff --git a/app/components/storefront/product/card.js b/app/components/storefront/product/card.js
new file mode 100644
index 0000000..d078777
--- /dev/null
+++ b/app/components/storefront/product/card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/card';
diff --git a/app/components/storefront/product/category-form.js b/app/components/storefront/product/category-form.js
new file mode 100644
index 0000000..bf80540
--- /dev/null
+++ b/app/components/storefront/product/category-form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/category-form';
diff --git a/app/components/storefront/product/category-sidebar.js b/app/components/storefront/product/category-sidebar.js
new file mode 100644
index 0000000..d77abec
--- /dev/null
+++ b/app/components/storefront/product/category-sidebar.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/category-sidebar';
diff --git a/app/components/storefront/product/collection.js b/app/components/storefront/product/collection.js
new file mode 100644
index 0000000..38e6603
--- /dev/null
+++ b/app/components/storefront/product/collection.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/collection';
diff --git a/app/components/storefront/product/form.js b/app/components/storefront/product/form.js
new file mode 100644
index 0000000..f673ac8
--- /dev/null
+++ b/app/components/storefront/product/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form';
diff --git a/app/components/storefront/product/form/addons.js b/app/components/storefront/product/form/addons.js
new file mode 100644
index 0000000..aec0bde
--- /dev/null
+++ b/app/components/storefront/product/form/addons.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/addons';
diff --git a/app/components/storefront/product/form/availability.js b/app/components/storefront/product/form/availability.js
new file mode 100644
index 0000000..9f2b3bd
--- /dev/null
+++ b/app/components/storefront/product/form/availability.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/availability';
diff --git a/app/components/storefront/product/form/basics.js b/app/components/storefront/product/form/basics.js
new file mode 100644
index 0000000..032638a
--- /dev/null
+++ b/app/components/storefront/product/form/basics.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/basics';
diff --git a/app/components/storefront/product/form/flags.js b/app/components/storefront/product/form/flags.js
new file mode 100644
index 0000000..22e89ec
--- /dev/null
+++ b/app/components/storefront/product/form/flags.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/flags';
diff --git a/app/components/storefront/product/form/media.js b/app/components/storefront/product/form/media.js
new file mode 100644
index 0000000..61fddea
--- /dev/null
+++ b/app/components/storefront/product/form/media.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/media';
diff --git a/app/components/storefront/product/form/metadata.js b/app/components/storefront/product/form/metadata.js
new file mode 100644
index 0000000..4da1a78
--- /dev/null
+++ b/app/components/storefront/product/form/metadata.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/metadata';
diff --git a/app/components/storefront/product/form/pricing.js b/app/components/storefront/product/form/pricing.js
new file mode 100644
index 0000000..a5b9bb2
--- /dev/null
+++ b/app/components/storefront/product/form/pricing.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/pricing';
diff --git a/app/components/storefront/product/form/translations.js b/app/components/storefront/product/form/translations.js
new file mode 100644
index 0000000..b270f6e
--- /dev/null
+++ b/app/components/storefront/product/form/translations.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/translations';
diff --git a/app/components/storefront/product/form/variants.js b/app/components/storefront/product/form/variants.js
new file mode 100644
index 0000000..c5536d4
--- /dev/null
+++ b/app/components/storefront/product/form/variants.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/variants';
diff --git a/app/components/storefront/product/form/videos.js b/app/components/storefront/product/form/videos.js
new file mode 100644
index 0000000..e695f6b
--- /dev/null
+++ b/app/components/storefront/product/form/videos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/storefront/product/form/videos';
diff --git a/app/components/tracking-stop-progress.js b/app/components/tracking-stop-progress.js
new file mode 100644
index 0000000..e51c81f
--- /dev/null
+++ b/app/components/tracking-stop-progress.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/tracking-stop-progress';
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/order-panel.js b/app/components/widget/kpi-aov.js
similarity index 78%
rename from app/components/order-panel.js
rename to app/components/widget/kpi-aov.js
index f33927a..7428305 100644
--- a/app/components/order-panel.js
+++ b/app/components/widget/kpi-aov.js
@@ -1 +1 @@
-export { default } from '@fleetbase/storefront-engine/components/order-panel';
+export { default } from '@fleetbase/storefront-engine/components/widget/kpi-aov';
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-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-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/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/order-panel/details.js b/app/components/widget/kpi-orders.js
similarity index 73%
rename from app/components/order-panel/details.js
rename to app/components/widget/kpi-orders.js
index 52ede46..7a30606 100644
--- a/app/components/order-panel/details.js
+++ b/app/components/widget/kpi-orders.js
@@ -1 +1 @@
-export { default } from '@fleetbase/storefront-engine/components/order-panel/details';
+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/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/storefront-kpi-tile.js b/app/components/widget/storefront-kpi-tile.js
new file mode 100644
index 0000000..1ef9174
--- /dev/null
+++ b/app/components/widget/storefront-kpi-tile.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/components/widget/storefront-kpi-tile';
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/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/order-actions.js b/app/services/order-actions.js
deleted file mode 100644
index fbebb53..0000000
--- a/app/services/order-actions.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/storefront-engine/services/order-actions';
diff --git a/app/services/storefront-dashboard.js b/app/services/storefront-dashboard.js
new file mode 100644
index 0000000..eee5bf3
--- /dev/null
+++ b/app/services/storefront-dashboard.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/services/storefront-dashboard';
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/services/storefront-order-workflow.js b/app/services/storefront-order-workflow.js
new file mode 100644
index 0000000..493c3b6
--- /dev/null
+++ b/app/services/storefront-order-workflow.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/storefront-engine/services/storefront-order-workflow';
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/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..4fa02a5 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",
@@ -44,9 +44,9 @@
},
"dependencies": {
"@babel/core": "^7.23.2",
- "@fleetbase/ember-core": "^0.3.17",
- "@fleetbase/ember-ui": "^0.3.25",
- "@fleetbase/fleetops-data": "^0.1.25",
+ "@fleetbase/ember-core": "^0.3.21",
+ "@fleetbase/ember-ui": "^0.3.33",
+ "@fleetbase/fleetops-data": "^0.1.36",
"@fortawesome/ember-fontawesome": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
@@ -78,7 +78,7 @@
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
- "ember-concurrency": "^3.1.1",
+ "ember-concurrency": "^4.0.6",
"ember-data": "^4.12.5",
"ember-engines": "^0.9.0",
"ember-load-initializers": "^2.1.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b52b1f8..d0eaac8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,14 +12,14 @@ importers:
specifier: ^7.23.2
version: 7.29.7
'@fleetbase/ember-core':
- specifier: ^0.3.17
- version: 0.3.19(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
+ specifier: ^0.3.21
+ version: 0.3.21(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
'@fleetbase/ember-ui':
- specifier: ^0.3.25
- version: 0.3.32(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)
+ specifier: ^0.3.33
+ version: 0.3.33(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)
'@fleetbase/fleetops-data':
- specifier: ^0.1.25
- version: 0.1.34(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
+ specifier: ^0.1.36
+ version: 0.1.36(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
'@fortawesome/ember-fontawesome':
specifier: ^2.0.0
version: 2.0.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(rollup@2.80.0)(webpack@5.107.2(postcss@8.5.15))
@@ -109,8 +109,8 @@ importers:
specifier: ^4.0.2
version: 4.0.2
ember-concurrency:
- specifier: ^3.1.1
- version: 3.1.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
+ specifier: ^4.0.6
+ version: 4.0.6(@babel/core@7.29.7)
ember-data:
specifier: ^4.12.5
version: 4.12.8(@babel/core@7.29.7)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15))
@@ -1263,16 +1263,20 @@ packages:
peerDependencies:
ember-source: '>= 4.0.0'
- '@fleetbase/ember-core@0.3.19':
- resolution: {integrity: sha512-5phquVcfcpRtoxBvyAYDS9bmdPx5c46mXLrcvjmTFSdGElw9CZv44dRkuE8UTgpapMFfPc7vkA9/KwKFAgQ9JA==}
+ '@fleetbase/ember-core@0.3.20':
+ resolution: {integrity: sha512-eqY15urfqFkC26TJO/irdCxke9VE7ywcLodM3K6iDEXR+FFk50YIWnXzvU8PvEQ1EZCRJdyEERvCc9FnypXFQA==}
engines: {node: '>= 18'}
- '@fleetbase/ember-ui@0.3.32':
- resolution: {integrity: sha512-OPcFNwdQ4SBB0DryWbxbQ+WBMwr9Nl7MVg31r6qPAjmVLrL2MT22/BDH5jLNMHgyaLR5zJk0vtvl3SNX88TJzg==}
+ '@fleetbase/ember-core@0.3.21':
+ resolution: {integrity: sha512-hBIisQfGuuWolyzUnIx+l6M7JtiLMXxhOM1UlAITJ3kIXK0gLclHX3JxF9zoonSnkzNtOe9/TbzIFuKlJVbG9w==}
engines: {node: '>= 18'}
- '@fleetbase/fleetops-data@0.1.34':
- resolution: {integrity: sha512-qIpLGtaOMopPaOlXql0bUlgK8dAtLcPHsPOiOniGhvUelagQiFuNAxEqJy3/kiQf++MqCx76z56VsqVqlgX6Og==}
+ '@fleetbase/ember-ui@0.3.33':
+ resolution: {integrity: sha512-XUkRFR/hPfXq1pGadPvEZVTGU7f1D4IHzXcyKVmppY5AxPjgk8PeteoFnNdwZmbGdkTFzifydeG313DltBWlUg==}
+ engines: {node: '>= 18'}
+
+ '@fleetbase/fleetops-data@0.1.36':
+ resolution: {integrity: sha512-w8FmeoCYcWHIoaN5Oxk8XDkxI+va1dwUZCeFDJ2/6oaJ/n6sCFknki67AmYJ0cFJEqHPskUgA8uaQlqw9ZGJXA==}
engines: {node: '>= 18'}
'@fleetbase/intl-lint@0.0.1':
@@ -3880,12 +3884,6 @@ packages:
resolution: {integrity: sha512-sz6sTIXN/CuLb5wdpauFa+rWXuvXXSnSHS4kuNzU5GSMDX1pLBWSuovoUk61FUe6CYRqBmT1/UushObwBGickQ==}
engines: {node: 10.* || 12.* || 14.* || >= 16}
- ember-concurrency@3.1.1:
- resolution: {integrity: sha512-doXFYYfy1C7jez+jDDlfahTp03QdjXeSY/W3Zbnx/q3UNJ9g10Shf2d7M/HvWo/TC22eU+6dPLIpqd/6q4pR+Q==}
- engines: {node: 16.* || >= 18}
- peerDependencies:
- ember-source: ^3.28.0 || ^4.0.0 || >=5.0.0
-
ember-concurrency@4.0.6:
resolution: {integrity: sha512-Ikwl2YwXVe8aBwrT1deWTcUVxVu6KxS1qeU1ks3EML1Q/nxwKgxCkGqTJavxczawO8H/SIW45dV4r7z5Yqd2Xg==}
engines: {node: 16.* || >= 18}
@@ -9960,7 +9958,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@fleetbase/ember-core@0.3.19(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))':
+ '@fleetbase/ember-core@0.3.20(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))':
dependencies:
'@babel/core': 7.29.7
compress-json: 3.4.0
@@ -9977,12 +9975,11 @@ snapshots:
ember-intl: 6.3.2(@babel/core@7.29.7)(webpack@5.107.2(postcss@8.5.15))
ember-loading: 2.0.0(@babel/core@7.29.7)
ember-local-storage: 2.0.7(@babel/core@7.29.7)
- ember-simple-auth: 6.1.0(@babel/core@7.29.7)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)
+ ember-simple-auth: 6.1.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)
ember-wormhole: 0.6.1
socketcluster-client: 17.2.2
transitivePeerDependencies:
- '@ember/string'
- - '@ember/test-helpers'
- '@glint/template'
- bufferutil
- ember-resolver
@@ -9993,7 +9990,39 @@ snapshots:
- utf-8-validate
- webpack
- '@fleetbase/ember-ui@0.3.32(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)':
+ '@fleetbase/ember-core@0.3.21(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))':
+ dependencies:
+ '@babel/core': 7.29.7
+ compress-json: 3.4.0
+ date-fns: 2.30.0
+ ember-auto-import: 2.13.1(webpack@5.107.2(postcss@8.5.15))
+ ember-can: 6.0.0(@babel/core@7.29.7)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
+ ember-cli-babel: 8.3.1(@babel/core@7.29.7)
+ ember-cli-htmlbars: 6.3.0
+ ember-cli-notifications: 9.1.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
+ ember-concurrency: 4.0.6(@babel/core@7.29.7)
+ ember-decorators: 6.1.1
+ ember-get-config: 2.1.1(@babel/core@7.29.7)
+ ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
+ ember-intl: 6.3.2(@babel/core@7.29.7)(webpack@5.107.2(postcss@8.5.15))
+ ember-loading: 2.0.0(@babel/core@7.29.7)
+ ember-local-storage: 2.0.7(@babel/core@7.29.7)
+ ember-simple-auth: 6.1.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)
+ ember-wormhole: 0.6.1
+ socketcluster-client: 17.2.2
+ transitivePeerDependencies:
+ - '@ember/string'
+ - '@glint/template'
+ - bufferutil
+ - ember-resolver
+ - ember-source
+ - eslint
+ - supports-color
+ - typescript
+ - utf-8-validate
+ - webpack
+
+ '@fleetbase/ember-ui@0.3.33(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(@glimmer/component@1.1.2(@babel/core@7.29.7))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(postcss@8.5.15)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.7))(webpack@5.107.2(postcss@8.5.15))(yaml@2.9.0)':
dependencies:
'@babel/core': 7.29.7
'@ember/render-modifiers': 2.1.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
@@ -10099,16 +10128,15 @@ snapshots:
- webpack-command
- yaml
- '@fleetbase/fleetops-data@0.1.34(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))':
+ '@fleetbase/fleetops-data@0.1.36(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))':
dependencies:
'@babel/core': 7.29.7
- '@fleetbase/ember-core': 0.3.19(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
+ '@fleetbase/ember-core': 0.3.20(@ember/string@3.1.1)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1)(webpack@5.107.2(postcss@8.5.15))
date-fns: 2.30.0
ember-cli-babel: 8.3.1(@babel/core@7.29.7)
ember-cli-htmlbars: 6.3.0
transitivePeerDependencies:
- '@ember/string'
- - '@ember/test-helpers'
- '@glint/template'
- bufferutil
- ember-resolver
@@ -13842,20 +13870,6 @@ snapshots:
- '@babel/core'
- supports-color
- ember-concurrency@3.1.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))):
- dependencies:
- '@babel/helper-plugin-utils': 7.29.7
- '@babel/types': 7.29.7
- '@glimmer/tracking': 1.1.2
- ember-cli-babel: 7.26.11
- ember-cli-babel-plugin-helpers: 1.1.1
- ember-cli-htmlbars: 6.3.0
- ember-compatibility-helpers: 1.2.7(@babel/core@7.29.7)
- ember-source: 5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15))
- transitivePeerDependencies:
- - '@babel/core'
- - supports-color
-
ember-concurrency@4.0.6(@babel/core@7.29.7):
dependencies:
'@babel/helper-module-imports': 7.29.7
@@ -14291,7 +14305,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-simple-auth@6.1.0(@babel/core@7.29.7)(@ember/test-helpers@3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15)))(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1):
+ ember-simple-auth@6.1.0(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(eslint@8.57.1):
dependencies:
'@babel/eslint-parser': 7.29.7(@babel/core@7.29.7)(eslint@8.57.1)
'@ember/test-waiters': 3.1.0
@@ -14300,8 +14314,6 @@ snapshots:
ember-cli-is-package-missing: 1.0.0
ember-cookies: 1.3.0(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))
silent-error: 1.1.1
- optionalDependencies:
- '@ember/test-helpers': 3.3.1(@babel/core@7.29.7)(ember-source@5.4.1(@babel/core@7.29.7)(@glimmer/component@1.1.2(@babel/core@7.29.7))(rsvp@4.8.5)(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15))
transitivePeerDependencies:
- '@babel/core'
- '@glint/template'
diff --git a/server/seeders/Testing/CatalogAndProductsSeeder.php b/server/seeders/Testing/CatalogAndProductsSeeder.php
new file mode 100644
index 0000000..d133279
--- /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::PUBLISHED,
+ ]);
+ }
+
+ 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..5bc605b
--- /dev/null
+++ b/server/seeders/Testing/CheckoutOrdersSeeder.php
@@ -0,0 +1,479 @@
+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);
+ $orderUuids = $this->seededUuids(Order::class);
+ $transactionUuids = $this->seededUuids(Transaction::class);
+
+ $this->purgeSeededLedgerJournals($orderUuids);
+ $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 purgeSeededLedgerJournals(array $orderUuids): void
+ {
+ if (!Schema::connection($this->fleetbaseConnection())->hasTable('ledger_journals')) {
+ return;
+ }
+
+ DB::connection($this->fleetbaseConnection())
+ ->table('ledger_journals')
+ ->where('type', 'storefront_sale')
+ ->where(function ($query) use ($orderUuids) {
+ $query->where('meta->seed', static::SEED_NAME);
+
+ if (!empty($orderUuids)) {
+ $query->orWhereIn('meta->order_uuid', $orderUuids);
+ }
+ })
+ ->delete();
+ }
+
+ 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..2e63211
--- /dev/null
+++ b/server/seeders/Testing/Concerns/SeedsTestingData.php
@@ -0,0 +1,224 @@
+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
+ {
+ $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
+ {
+ /** @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/AnalyticsController.php b/server/src/Http/Controllers/AnalyticsController.php
new file mode 100644
index 0000000..cedcc76
--- /dev/null
+++ b/server/src/Http/Controllers/AnalyticsController.php
@@ -0,0 +1,360 @@
+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 = $this->cartStateItems($checkout->cart_state);
+ 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);
+ }
+ });
+
+ $productRecords = Product::with(['primaryImage', 'files'])->whereIn('public_id', array_keys($products))->get()->keyBy('public_id');
+
+ return response()->json([
+ 'products' => collect($products)->map(function ($product) use ($productRecords) {
+ $productRecord = $productRecords->get($product['id']);
+
+ $product['name'] = $productRecord->name ?? $product['name'];
+ $product['primary_image_url'] = $productRecord->primary_image_url ?? null;
+
+ return $product;
+ })->sortByDesc('revenue')->take(8)->values(),
+ ]);
+ }
+
+ 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);
+ $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/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php
index c4d963c..5ab1ffb 100644
--- a/server/src/Http/Controllers/OrderController.php
+++ b/server/src/Http/Controllers/OrderController.php
@@ -4,8 +4,12 @@
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;
@@ -18,6 +22,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 +36,55 @@ class OrderController extends FleetbaseOrderController
*/
public $filter = \Fleetbase\Storefront\Http\Filter\OrderFilter::class;
+ public function onQueryRecord(Builder $query): void
+ {
+ $query->with(['customer', 'transaction', 'payload', 'driverAssigned', 'orderConfig', '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',
+ ];
+ }
+
+ private function orderResponse(Order $order): array
+ {
+ return [
+ 'status' => $order->status,
+ 'order' => new StorefrontOrderResource($order->fresh($this->detailRelations())),
+ ];
+ }
+
/**
* Accept an order by incrementing status to preparing.
*
@@ -65,11 +125,7 @@ public function acceptOrder(Request $request)
} catch (\Exception $e) {
}
- return response()->json([
- 'status' => 'ok',
- 'order' => $order->public_id,
- 'status' => $order->status,
- ]);
+ return $this->orderResponse($order);
}
/**
@@ -94,11 +150,7 @@ public function markOrderAsReady(Request $request)
if ($order->isMeta('is_pickup')) {
$order->updateStatus('pickup_ready');
- return response()->json([
- 'status' => 'ok',
- 'order' => $order->public_id,
- 'status' => $order->status,
- ]);
+ return $this->orderResponse($order);
}
// toggle order to adhoc
@@ -111,14 +163,11 @@ public function markOrderAsReady(Request $request)
$order->assignDriver($driver);
}
- // update activity to dispatched
+ // Dispatch the order and move Storefront delivery orders into preparation.
$order->dispatchWithActivity();
+ $order->updateStatus('preparing');
- return response()->json([
- 'status' => 'ok',
- 'order' => $order->public_id,
- 'status' => $order->status,
- ]);
+ return $this->orderResponse($order);
}
/**
@@ -150,11 +199,7 @@ public function markOrderAsPreparing(Request $request)
return response()->error('Unable to trigger order preparing.');
}
- return response()->json([
- 'status' => 'ok',
- 'order' => $order->public_id,
- 'status' => $order->status,
- ]);
+ return $this->orderResponse($order);
}
/**
@@ -176,14 +221,32 @@ public function markOrderAsCompleted(Request $request)
// Patch order config
Storefront::patchOrderConfig($order);
- // update activity to completed
- $order->updateStatus('completed');
+ $order->updateStatus($order->isMeta('is_pickup') ? 'picked_up' : 'completed');
- return response()->json([
- 'status' => 'ok',
- 'order' => $order->public_id,
- 'status' => $order->status,
- ]);
+ return $this->orderResponse($order);
+ }
+
+ public function unassignDriver(Request $request)
+ {
+ /** @var Order */
+ $order = Order::where('uuid', $request->order)->whereNull('deleted_at')->with(['driverAssigned'])->first();
+
+ if (!$order) {
+ return response()->json([
+ 'error' => 'No order to update!',
+ ], 400);
+ }
+
+ if ($order->driverAssigned) {
+ $order->driverAssigned->unassignCurrentOrder();
+ }
+
+ $order->forceFill([
+ 'driver_assigned_uuid' => null,
+ 'vehicle_assigned_uuid' => null,
+ ])->save();
+
+ return $this->orderResponse($order);
}
/**
@@ -204,13 +267,8 @@ public function rejectOrder(Request $request)
// Patch order config
Storefront::patchOrderConfig($order);
- // update activity to dispatched
- $order->updateStatus('cancel');
+ $order->updateStatus('canceled');
- return response()->json([
- 'status' => 'ok',
- 'order' => $order->public_id,
- 'status' => $order->status,
- ]);
+ return $this->orderResponse($order);
}
}
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
diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php
new file mode 100644
index 0000000..f2d3485
--- /dev/null
+++ b/server/src/Http/Resources/Index/Order.php
@@ -0,0 +1,64 @@
+customer_name;
+ $data['transaction_amount'] = $this->transaction_amount;
+ $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/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/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php
new file mode 100644
index 0000000..d697b87
--- /dev/null
+++ b/server/src/Http/Resources/v1/Index/Order.php
@@ -0,0 +1,9 @@
+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) {
@@ -168,6 +178,8 @@ function ($router, $controller) {
$router->post('ready', $controller('markOrderAsReady'));
$router->post('preparing', $controller('markOrderAsPreparing'));
$router->post('completed', $controller('markOrderAsCompleted'));
+ $router->patch('cancel', $controller('rejectOrder'));
+ $router->post('unassign-driver', $controller('unassignDriver'));
}
);
$router->fleetbaseRoutes(
diff --git a/server/tests/Feature.php b/server/tests/Feature.php
index 61cd84c..c012651 100644
--- a/server/tests/Feature.php
+++ b/server/tests/Feature.php
@@ -1,5 +1,150 @@
toBeTrue();
+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();
+ $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');
+});
+
+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');
+});
+
+test('testing seeder purges seeded ledger storefront sale journals before orders', function () {
+ $seeder = file_get_contents(__DIR__ . '/../seeders/Testing/CheckoutOrdersSeeder.php');
+
+ expect($seeder)
+ ->toContain('$orderUuids = $this->seededUuids(Order::class)')
+ ->toContain('$this->purgeSeededLedgerJournals($orderUuids)')
+ ->toContain("->table('ledger_journals')")
+ ->toContain("->where('type', 'storefront_sale')")
+ ->toContain("->where('meta->seed', static::SEED_NAME)")
+ ->toContain("->whereIn('meta->order_uuid', $orderUuids)");
});
diff --git a/tests/integration/components/modals/create-product-category-test.js b/tests/integration/components/modals/create-product-category-test.js
index 778354c..195f28c 100644
--- a/tests/integration/components/modals/create-product-category-test.js
+++ b/tests/integration/components/modals/create-product-category-test.js
@@ -6,21 +6,19 @@ import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | modals/create-product-category', 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) { ... });
+ test('it renders the category form', async function (assert) {
+ this.set('options', {
+ category: {
+ name: 'Lunch',
+ description: 'Lunch menu',
+ icon_url: 'https://example.com/lunch.png',
+ },
+ uploadNewPhoto() {},
+ });
- await render(hbs`
`);
+ 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-product-category-form__media').exists();
+ assert.dom(this.element).includesText('Lunch');
});
});
diff --git a/tests/integration/components/modals/manage-addons-test.js b/tests/integration/components/modals/manage-addons-test.js
index ac961e3..6861799 100644
--- a/tests/integration/components/modals/manage-addons-test.js
+++ b/tests/integration/components/modals/manage-addons-test.js
@@ -1,26 +1,33 @@
+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 StoreStubService extends Service {
+ query() {
+ return [];
+ }
+}
+
module('Integration | Component | modals/manage-addons', 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`
`);
+ hooks.beforeEach(function () {
+ this.owner.register('service:store', StoreStubService);
+ });
- assert.dom(this.element).hasText('');
+ test('it renders addon management', async function (assert) {
+ this.set('options', {
+ store: {
+ id: 'store_1',
+ currency: 'USD',
+ },
+ });
- // Template block usage:
- await render(hbs`
-
- template block text
-
- `);
+ await render(hbs`
`);
- assert.dom(this.element).hasText('template block text');
+ assert.dom('[data-test-storefront-product-addon-management]').exists();
+ assert.dom('[data-test-storefront-product-addon-management]').includesText('Checkout Add-ons');
});
});
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/integration/components/storefront/product/card-test.js b/tests/integration/components/storefront/product/card-test.js
new file mode 100644
index 0000000..80ca10a
--- /dev/null
+++ b/tests/integration/components/storefront/product/card-test.js
@@ -0,0 +1,76 @@
+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/product/card', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders product catalog details', async function (assert) {
+ this.set('product', {
+ id: 'product_1',
+ name: 'Signature Bento',
+ description: 'A complete lunch set',
+ sku: 'BENTO-1',
+ status: 'published',
+ price: 1200,
+ sale_price: 990,
+ currency: 'USD',
+ is_available: true,
+ is_on_sale: true,
+ is_service: false,
+ is_bookable: false,
+ is_recommended: true,
+ updatedAgo: '2 days',
+ primary_image_url: 'https://example.com/bento.png',
+ category: { name: 'Lunch' },
+ variants: [{ name: 'Size' }],
+ addon_categories: [{ name: 'Sides' }],
+ });
+ this.set('deleteProduct', () => {});
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-storefront-product-card]').exists();
+ assert.dom('[data-test-storefront-product-card]').includesText('Signature Bento');
+ assert.dom('[data-test-storefront-product-card]').includesText('Lunch');
+ assert.dom('[data-test-storefront-product-card]').includesText('BENTO-1');
+ assert.dom('[data-test-storefront-product-card]').includesText('1 variants');
+ assert.dom('[data-test-storefront-product-card]').includesText('1 add-ons');
+ assert.dom('[data-test-storefront-product-card]').includesText('Recommended');
+ });
+
+ test('it renders legacy available status as published', async function (assert) {
+ this.set('product', {
+ id: 'product_1',
+ name: 'Legacy Product',
+ description: 'Older product record',
+ sku: 'LEGACY-1',
+ status: 'available',
+ price: 1200,
+ currency: 'USD',
+ is_available: true,
+ primary_image_url: 'https://example.com/legacy.png',
+ variants: [],
+ addon_categories: [],
+ });
+ this.set('deleteProduct', () => {});
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-storefront-product-card]').includesText('Published');
+ assert.dom('[data-test-storefront-product-card]').doesNotIncludeText('Available');
+ });
+});
diff --git a/tests/integration/components/storefront/product/category-sidebar-test.js b/tests/integration/components/storefront/product/category-sidebar-test.js
new file mode 100644
index 0000000..7f2d2e6
--- /dev/null
+++ b/tests/integration/components/storefront/product/category-sidebar-test.js
@@ -0,0 +1,44 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { click, render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | storefront/product/category-sidebar', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders and selects product categories', async function (assert) {
+ assert.expect(5);
+
+ const lunch = { id: 'cat_1', name: 'Lunch', description: 'Lunch menu' };
+ const drinks = { id: 'cat_2', name: 'Drinks', description: 'Beverages' };
+
+ this.setProperties({
+ categories: [lunch, drinks],
+ activeCategory: lunch,
+ selectCategory(category) {
+ assert.strictEqual(category, drinks);
+ },
+ viewAll() {
+ assert.ok(true, 'view all callback fired');
+ },
+ createCategory() {},
+ });
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-storefront-product-category-sidebar]').exists();
+ assert.dom('[data-test-storefront-product-category-sidebar]').includesText('All Products');
+ assert.dom('[data-test-storefront-product-category-sidebar]').includesText('Lunch');
+ assert.dom('[data-test-storefront-product-category-sidebar]').includesText('Drinks');
+
+ await click('[data-test-storefront-product-category="cat_2"]');
+ });
+});
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/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/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..ec65dca
--- /dev/null
+++ b/tests/integration/components/widget/revenue-trend-test.js
@@ -0,0 +1,148 @@
+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';
+
+let revenueTrendResponse;
+
+class StorefrontStubService extends Service {
+ activeStore = { public_id: 'store_1', currency: 'USD' };
+ on() {}
+}
+
+class StorefrontDashboardStubService extends Service {
+ withStore(store) {
+ return { store };
+ }
+
+ on() {}
+}
+
+class FetchStubService extends Service {
+ get() {
+ return revenueTrendResponse;
+ }
+}
+
+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;
+ }
+
+ get revenueData() {
+ return this.args.datasets.find((dataset) => dataset.label === 'Revenue')?.data.join(',');
+ }
+
+ get ordersData() {
+ return this.args.datasets.find((dataset) => dataset.label === 'Orders')?.data.join(',');
+ }
+
+ get revenueTick() {
+ return this.args.options.scales.y.ticks.callback(this.args.datasets.find((dataset) => dataset.label === 'Revenue')?.data[0]);
+ }
+
+ get revenueTooltip() {
+ return this.args.options.plugins.tooltip.callbacks.label({
+ dataset: { label: 'Revenue' },
+ parsed: { y: this.args.datasets.find((dataset) => dataset.label === 'Revenue')?.data[0] },
+ });
+ }
+
+ get ordersTooltip() {
+ return this.args.options.plugins.tooltip.callbacks.label({
+ dataset: { label: 'Orders' },
+ parsed: { y: this.args.datasets.find((dataset) => dataset.label === 'Orders')?.data[0] },
+ });
+ }
+}
+
+module('Integration | Component | widget/revenue-trend', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ revenueTrendResponse = {
+ labels: ['2026-06-01', '2026-06-02'],
+ datasets: [
+ { label: 'Revenue', data: [267545, 0] },
+ { label: 'Orders', data: [29, 0] },
+ ],
+ summary: { revenue: 267545, orders: 29, currency: 'USD' },
+ };
+
+ this.owner.register('service:storefront', StorefrontStubService);
+ this.owner.register('service:storefront-dashboard', StorefrontDashboardStubService);
+ this.owner.register('service:fetch', FetchStubService);
+ this.owner.register(
+ 'component:chart',
+ setComponentTemplate(
+ hbs`
+
{{this.legendBoxWidth}}/{{this.maxTicksLimit}}/{{this.xTickFontSize}}
+
{{this.revenueData}}
+
{{this.ordersData}}
+
{{this.revenueTick}}
+
{{this.revenueTooltip}}
+
{{this.ordersTooltip}}
+ `,
+ 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');
+ });
+
+ test('it normalizes minor-unit revenue data for the chart and formats currency labels', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.storefront-widget-subtitle').hasText('$2,675.45 across 29 orders');
+ assert.dom('[data-test-revenue-data]').hasText('2675.45,0');
+ assert.dom('[data-test-orders-data]').hasText('29,0');
+ assert.dom('[data-test-revenue-tick]').hasText('$2,675.45');
+ assert.dom('[data-test-revenue-tooltip]').hasText('Revenue: $2,675.45');
+ assert.dom('[data-test-orders-tooltip]').hasText('Orders: 29');
+ });
+
+ test('it uses currency precision when normalizing chart revenue', async function (assert) {
+ revenueTrendResponse = {
+ labels: ['2026-06-01'],
+ datasets: [
+ { label: 'Revenue', data: [267545] },
+ { label: 'Orders', data: [4] },
+ ],
+ summary: { revenue: 267545, orders: 4, currency: 'JPY' },
+ };
+
+ await render(hbs`
`);
+
+ assert.dom('[data-test-revenue-data]').hasText('267545');
+
+ revenueTrendResponse = {
+ labels: ['2026-06-01'],
+ datasets: [
+ { label: 'Revenue', data: [1234567] },
+ { label: 'Orders', data: [4] },
+ ],
+ summary: { revenue: 1234567, orders: 4, currency: 'KWD' },
+ };
+
+ await render(hbs`
`);
+
+ assert.dom('[data-test-revenue-data]').hasText('1234.567');
+ });
+});
diff --git a/tests/integration/components/widget/storefront-kpi-tile-test.js b/tests/integration/components/widget/storefront-kpi-tile-test.js
new file mode 100644
index 0000000..5efb3cd
--- /dev/null
+++ b/tests/integration/components/widget/storefront-kpi-tile-test.js
@@ -0,0 +1,85 @@
+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/storefront-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');
+ });
+
+ 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/top-products-test.js b/tests/integration/components/widget/top-products-test.js
new file mode 100644
index 0000000..0ff1010
--- /dev/null
+++ b/tests/integration/components/widget/top-products-test.js
@@ -0,0 +1,51 @@
+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', primary_image_url: 'https://example.com/bento.png' }],
+ };
+ }
+ }
+
+ 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');
+ assert.dom('.storefront-product-list-image').hasAttribute('src', 'https://example.com/bento.png');
+ });
+
+ 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.');
+ });
+});
diff --git a/tests/unit/extension-test.js b/tests/unit/extension-test.js
new file mode 100644
index 0000000..6a2cd9a
--- /dev/null
+++ b/tests/unit/extension-test.js
@@ -0,0 +1,70 @@
+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-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.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']
+ );
+ });
+});
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 });
});
});
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
deleted file mode 100644
index de91b25..0000000
--- a/tests/unit/services/order-actions-test.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { module, test } from 'qunit';
-import { setupTest } from 'dummy/tests/helpers';
-
-module('Unit | Service | order-actions', function (hooks) {
- setupTest(hooks);
-
- // TODO: Replace this with your real tests.
- test('it exists', function (assert) {
- let service = this.owner.lookup('service:order-actions');
- assert.ok(service);
- });
-});
diff --git a/tests/unit/services/storefront-dashboard-test.js b/tests/unit/services/storefront-dashboard-test.js
new file mode 100644
index 0000000..26f1578
--- /dev/null
+++ b/tests/unit/services/storefront-dashboard-test.js
@@ -0,0 +1,51 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+
+module('Unit | Service | storefront-dashboard', function (hooks) {
+ setupTest(hooks);
+
+ test('it defaults to the last 30 days', function (assert) {
+ const service = this.owner.lookup('service:storefront-dashboard');
+
+ assert.strictEqual(service.label, 'Last 30 Days');
+ assert.ok(service.start, 'start date is set');
+ assert.ok(service.end, 'end date is set');
+ assert.deepEqual(service.queryParams, { start: service.start, end: service.end });
+ assert.deepEqual(service.withStore('store_1'), { store: 'store_1', start: service.start, end: service.end });
+ });
+
+ test('it updates from date picker selections', function (assert) {
+ assert.expect(5);
+
+ const service = this.owner.lookup('service:storefront-dashboard');
+ const done = assert.async();
+
+ service.on('periodChanged', (queryParams) => {
+ assert.deepEqual(queryParams, { start: '2026-05-01', end: '2026-05-31' });
+ assert.strictEqual(service.version, 1);
+ done();
+ });
+
+ service.selectDates({ formattedDate: ['2026-05-01', '2026-05-31'] });
+
+ assert.strictEqual(service.label, 'Custom');
+ assert.strictEqual(service.start, '2026-05-01');
+ assert.strictEqual(service.end, '2026-05-31');
+ });
+
+ test('it updates from quick-select ranges', function (assert) {
+ const service = this.owner.lookup('service:storefront-dashboard');
+
+ service.selectRange({
+ label: 'Last 7 Days',
+ getValue() {
+ return [new Date(2026, 4, 25), new Date(2026, 4, 31)];
+ },
+ });
+
+ assert.strictEqual(service.label, 'Last 7 Days');
+ assert.strictEqual(service.start, '2026-05-25');
+ assert.strictEqual(service.end, '2026-05-31');
+ assert.strictEqual(service.formattedRange, 'May 25, 2026 - May 31, 2026');
+ });
+});
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..a51a1d3
--- /dev/null
+++ b/tests/unit/services/storefront-order-actions-test.js
@@ -0,0 +1,178 @@
+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' });
+ });
+
+ test('it progresses action buttons for accepted and pickup ready orders', function (assert) {
+ class MenuServiceStub extends Service {
+ getMenuItems() {
+ return [];
+ }
+ }
+
+ this.owner.register('service:universe/menu-service', MenuServiceStub);
+
+ const service = this.owner.lookup('service:storefront-order-actions');
+
+ let actions = service.actionButtonsFor({ id: 'order_1', status: 'created', meta: {} })[0].items;
+ assert.strictEqual(actions[0].text, 'Accept order');
+
+ actions = service.actionButtonsFor({ id: 'order_1', status: 'accepted', meta: { is_pickup: false } })[0].items;
+ assert.strictEqual(actions[0].text, 'Mark as Ready');
+
+ actions = service.actionButtonsFor({ id: 'order_1', status: 'accepted', dispatched: true, meta: { is_pickup: false } })[0].items;
+ assert.strictEqual(actions[0].text, 'Mark as Ready');
+
+ actions = service.actionButtonsFor({ id: 'order_1', status: 'accepted', meta: { is_pickup: true } })[0].items;
+ assert.strictEqual(actions[0].text, 'Mark as Ready');
+
+ actions = service.actionButtonsFor({ id: 'order_1', status: 'pickup_ready', meta: { is_pickup: true } })[0].items;
+ assert.strictEqual(actions[0].text, 'Mark Picked Up');
+ });
+
+ test('it applies mutation status before invoking callbacks', function (assert) {
+ assert.expect(3);
+
+ class ResourceContextPanelStub extends Service {
+ overlays = [
+ {
+ id: 'storefront-order:order_1',
+ },
+ ];
+
+ update(id, definition) {
+ assert.strictEqual(id, 'storefront-order:order_1');
+ assert.strictEqual(definition.actionButtons[0].items[0].text, 'Mark as Ready');
+ }
+ }
+
+ class MenuServiceStub extends Service {
+ getMenuItems() {
+ return [];
+ }
+ }
+
+ 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');
+ const order = { id: 'order_1', status: 'created', meta: {} };
+
+ service.didMutateOrder(order, 'accepted', (mutatedOrder) => {
+ assert.strictEqual(mutatedOrder.status, 'accepted');
+ });
+ });
+
+ test('it detects assigned drivers before dispatch', function (assert) {
+ const service = this.owner.lookup('service:storefront-order-actions');
+
+ assert.true(service.hasAssignedDriver({ has_driver_assigned: true }));
+ assert.true(service.hasAssignedDriver({ driver_assigned: { id: 'driver_1' } }));
+ assert.true(service.hasAssignedDriver({ driver_assigned_uuid: 'driver_1' }));
+ assert.false(service.hasAssignedDriver({ has_driver_assigned: false, driver_assigned: null }));
+ });
+
+ test('it changes driver action label when a driver is assigned', function (assert) {
+ const service = this.owner.lookup('service:storefront-order-actions');
+
+ let actions = service.actionButtonsFor({ id: 'order_1', status: 'accepted', meta: {}, driver_assigned: null })[0].items;
+ assert.strictEqual(actions.find((action) => action.icon === 'id-card').text, 'Assign Driver');
+
+ actions = service.actionButtonsFor({ id: 'order_1', status: 'accepted', meta: {}, driver_assigned_uuid: 'driver_1' })[0].items;
+ assert.strictEqual(actions.find((action) => action.icon === 'user-minus').text, 'Unassign Driver');
+ });
+
+ test('it marks orders dispatched even when dispatch response keeps a pre-dispatch status', function (assert) {
+ assert.expect(4);
+
+ class ResourceContextPanelStub extends Service {
+ overlays = [
+ {
+ id: 'storefront-order:order_1',
+ },
+ ];
+
+ update(id, definition) {
+ assert.strictEqual(id, 'storefront-order:order_1');
+ assert.notStrictEqual(definition.actionButtons[0].items[0].text, 'Mark as Ready');
+ }
+ }
+
+ class MenuServiceStub extends Service {
+ getMenuItems() {
+ return [];
+ }
+ }
+
+ 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');
+ const order = { id: 'order_1', status: 'accepted', dispatched: false, meta: {} };
+
+ service.didDispatchOrder(order, 'accepted', (mutatedOrder) => {
+ assert.strictEqual(mutatedOrder.status, 'dispatched');
+ assert.true(mutatedOrder.dispatched);
+ });
+ });
+});
diff --git a/tests/unit/services/storefront-order-workflow-test.js b/tests/unit/services/storefront-order-workflow-test.js
new file mode 100644
index 0000000..f4ca948
--- /dev/null
+++ b/tests/unit/services/storefront-order-workflow-test.js
@@ -0,0 +1,55 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+
+module('Unit | Service | storefront-order-workflow', function (hooks) {
+ setupTest(hooks);
+
+ test('it preserves the default storefront operator flow', function (assert) {
+ const service = this.owner.lookup('service:storefront-order-workflow');
+ const orderConfig = { key: 'storefront', namespace: 'system:order-config:storefront', flow: {} };
+
+ assert.deepEqual(
+ service.primaryActionDescriptorsFor({ status: 'created', type: 'storefront', order_config: orderConfig, meta: {} }).map((descriptor) => descriptor.text),
+ ['Accept order']
+ );
+ assert.deepEqual(
+ service.primaryActionDescriptorsFor({ status: 'accepted', type: 'storefront', order_config: orderConfig, meta: {} }).map((descriptor) => descriptor.text),
+ ['Mark as Ready']
+ );
+ assert.deepEqual(service.primaryActionDescriptorsFor({ status: 'preparing', type: 'storefront', order_config: orderConfig, meta: {} }), []);
+ assert.deepEqual(
+ service.primaryActionDescriptorsFor({ status: 'pickup_ready', type: 'storefront', order_config: orderConfig, meta: { is_pickup: true } }).map((descriptor) => descriptor.text),
+ ['Mark Picked Up']
+ );
+ });
+
+ test('it derives custom config actions from next activities', function (assert) {
+ const service = this.owner.lookup('service:storefront-order-workflow');
+ const orderConfig = {
+ key: 'custom',
+ flow: {
+ packing: {
+ activities: ['quality_check'],
+ },
+ quality_check: {
+ code: 'quality_check',
+ status: 'Quality Check',
+ },
+ },
+ };
+
+ assert.deepEqual(
+ service.primaryActionDescriptorsFor({ status: 'packing', order_config: orderConfig, meta: {} }).map((descriptor) => descriptor.text),
+ ['Quality Check']
+ );
+ });
+
+ test('it detects assigned drivers from relationship uuid or flag', function (assert) {
+ const service = this.owner.lookup('service:storefront-order-workflow');
+
+ assert.true(service.hasAssignedDriver({ driver_assigned: { id: 'driver_1' } }));
+ assert.true(service.hasAssignedDriver({ driver_assigned_uuid: 'driver_1' }));
+ assert.true(service.hasAssignedDriver({ has_driver_assigned: true }));
+ assert.false(service.hasAssignedDriver({ has_driver_assigned: false, driver_assigned: null, driver_assigned_uuid: null }));
+ });
+});
diff --git a/tests/unit/utils/commerce-date-ranges-test.js b/tests/unit/utils/commerce-date-ranges-test.js
index dff13fe..6205c5f 100644
--- a/tests/unit/utils/commerce-date-ranges-test.js
+++ b/tests/unit/utils/commerce-date-ranges-test.js
@@ -1,10 +1,24 @@
-import commerceDateRanges from 'dummy/utils/commerce-date-ranges';
+import { predefinedDateRanges } from 'dummy/utils/commerce-date-ranges';
import { module, test } from 'qunit';
module('Unit | Utility | commerce-date-ranges', function () {
- // TODO: Replace this with your real tests.
- test('it works', function (assert) {
- let result = commerceDateRanges();
- assert.ok(result);
+ test('it uses a compact current-year preset list', function (assert) {
+ const currentYear = new Date().getFullYear();
+ const labels = predefinedDateRanges.map((range) => range.label);
+
+ assert.ok(labels.includes('Last 7 Days'), 'includes short rolling range');
+ assert.ok(labels.includes('Last 30 Days'), 'includes default rolling range');
+ assert.ok(labels.includes('Last 90 Days'), 'includes longer rolling range');
+ assert.ok(labels.includes('This Quarter'), 'includes quarter reporting');
+ assert.ok(labels.includes(`Q1 ${currentYear}`), 'includes current-year quarterly presets');
+ assert.ok(labels.includes('Black Friday Week'), 'includes dynamic commerce season');
+ assert.notOk(
+ labels.some((label) => label.includes('2024')),
+ 'does not include stale fixed-year presets'
+ );
+ assert.notOk(
+ labels.some((label) => label.includes('2023')),
+ 'does not include older fixed-year presets'
+ );
});
});
diff --git a/translations/ar-ae.yaml b/translations/ar-ae.yaml
index 29d96c1..b19c561 100644
--- a/translations/ar-ae.yaml
+++ b/translations/ar-ae.yaml
@@ -243,6 +243,10 @@ storefront:
widget-title: الطلبات الأخيرة
id-column: رقم الطلب
cancel-order: إلغاء الطلب
+ cancel-orders: إلغاء الطلبات
+ cancel-order-modal-title: إلغاء هذا الطلب؟
+ cancel-order-modal-body: سيؤدي هذا إلى إلغاء طلب Storefront وإيقاف أي نشاط تنفيذ متبقٍ.
+ cancel-order-success: "تم إلغاء الطلب {orderId}."
accept-order: قبول الطلب!
mark-as-ready: وضع علامة جاهز!
dispatch: إرسال
diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml
index 41fa7d8..eebf92e 100644
--- a/translations/bg-bg.yaml
+++ b/translations/bg-bg.yaml
@@ -250,6 +250,10 @@ storefront:
widget-title: Последни поръчки
id-column: ID на поръчката
cancel-order: Отмени поръчката
+ cancel-orders: Отмени поръчките
+ cancel-order-modal-title: Да отмените ли тази поръчка?
+ cancel-order-modal-body: Това ще отмени Storefront поръчката и ще спре оставащите действия по изпълнение.
+ cancel-order-success: "Поръчка {orderId} беше отменена."
accept-order: Приеми поръчката!
mark-as-ready: Отбележи като готово!
dispatch: Изпрати
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 0052abb..e5bc11b 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -233,6 +233,10 @@ storefront:
widget-title: Recent Orders
id-column: Order ID
cancel-order: Cancel Order
+ cancel-orders: Cancel Orders
+ cancel-order-modal-title: Cancel this order?
+ cancel-order-modal-body: This will cancel the Storefront order and stop any remaining fulfillment activity.
+ cancel-order-success: "Order {orderId} has been canceled."
accept-order: Accept Order!
mark-as-ready: Mark as Ready!
dispatch: Dispatch
diff --git a/translations/es-es.yaml b/translations/es-es.yaml
index e8084ad..62bb371 100644
--- a/translations/es-es.yaml
+++ b/translations/es-es.yaml
@@ -253,6 +253,10 @@ storefront:
widget-title: Pedidos recientes
id-column: ID de pedido
cancel-order: Cancelar pedido
+ cancel-orders: Cancelar pedidos
+ cancel-order-modal-title: ¿Cancelar este pedido?
+ cancel-order-modal-body: Esto cancelará el pedido de Storefront y detendrá cualquier actividad de cumplimiento restante.
+ cancel-order-success: "El pedido {orderId} ha sido cancelado."
accept-order: ¡Aceptar pedido!
mark-as-ready: ¡Marcar como listo!
dispatch: Enviar
diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml
index fcfa9e0..75d2880 100644
--- a/translations/fr-fr.yaml
+++ b/translations/fr-fr.yaml
@@ -257,6 +257,10 @@ storefront:
widget-title: Commandes récentes
id-column: ID de commande
cancel-order: Annuler la commande
+ cancel-orders: Annuler les commandes
+ cancel-order-modal-title: Annuler cette commande ?
+ cancel-order-modal-body: Cela annulera la commande Storefront et arrêtera toute activité de traitement restante.
+ cancel-order-success: "La commande {orderId} a été annulée."
accept-order: Accepter la commande !
mark-as-ready: Marquer comme prêt !
dispatch: Expédier
diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml
index f83a7ce..38aa4ff 100644
--- a/translations/mn-mn.yaml
+++ b/translations/mn-mn.yaml
@@ -247,6 +247,10 @@ storefront:
widget-title: Сүүлийн захиалгууд
id-column: Захиалгын дугаар
cancel-order: Захиалгыг цуцлах
+ cancel-orders: Захиалгуудыг цуцлах
+ cancel-order-modal-title: Энэ захиалгыг цуцлах уу?
+ cancel-order-modal-body: Энэ нь Storefront захиалгыг цуцалж, үлдсэн гүйцэтгэлийн ажиллагааг зогсооно.
+ cancel-order-success: "{orderId} захиалга цуцлагдлаа."
accept-order: Захиалгыг хүлээн авах!
mark-as-ready: Бэлэн гэж тэмдэглэх!
dispatch: Илгээх
diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml
index 4951b73..84bd755 100644
--- a/translations/pt-br.yaml
+++ b/translations/pt-br.yaml
@@ -253,6 +253,10 @@ storefront:
widget-title: Pedidos Recentes
id-column: ID do Pedido
cancel-order: Cancelar Pedido
+ cancel-orders: Cancelar Pedidos
+ cancel-order-modal-title: Cancelar este pedido?
+ cancel-order-modal-body: Isso cancelará o pedido Storefront e interromperá qualquer atividade de atendimento restante.
+ cancel-order-success: "O pedido {orderId} foi cancelado."
accept-order: Aceitar Pedido!
mark-as-ready: Marcar como Pronto!
dispatch: Despachar
diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml
index 1208cc5..df34f2a 100644
--- a/translations/ru-ru.yaml
+++ b/translations/ru-ru.yaml
@@ -250,6 +250,10 @@ storefront:
widget-title: Недавние заказы
id-column: ID заказа
cancel-order: Отменить заказ
+ cancel-orders: Отменить заказы
+ cancel-order-modal-title: Отменить этот заказ?
+ cancel-order-modal-body: Это отменит заказ Storefront и остановит оставшиеся действия по выполнению.
+ cancel-order-success: "Заказ {orderId} отменен."
accept-order: Принять заказ!
mark-as-ready: Отметить как готовый!
dispatch: Отправить
diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml
index f7ac746..e4d5d1a 100644
--- a/translations/vi-vn.yaml
+++ b/translations/vi-vn.yaml
@@ -249,6 +249,10 @@ storefront:
widget-title: Đơn hàng gần đây
id-column: Mã đơn hàng
cancel-order: Hủy đơn hàng
+ cancel-orders: Hủy đơn hàng
+ cancel-order-modal-title: Hủy đơn hàng này?
+ cancel-order-modal-body: Thao tác này sẽ hủy đơn hàng Storefront và dừng mọi hoạt động xử lý còn lại.
+ cancel-order-success: "Đơn hàng {orderId} đã bị hủy."
accept-order: Chấp nhận đơn hàng!
mark-as-ready: Đánh dấu là sẵn sàng!
dispatch: Giao hàng
diff --git a/translations/zh-cn.yaml b/translations/zh-cn.yaml
index f1dfec7..28fcab6 100644
--- a/translations/zh-cn.yaml
+++ b/translations/zh-cn.yaml
@@ -232,6 +232,10 @@ storefront:
widget-title: 最近订单
id-column: 订单编号
cancel-order: 取消订单
+ cancel-orders: 取消订单
+ cancel-order-modal-title: 取消此订单?
+ cancel-order-modal-body: 这将取消 Storefront 订单并停止所有剩余履约活动。
+ cancel-order-success: "订单 {orderId} 已取消。"
accept-order: 接受订单!
mark-as-ready: 标记为准备好!
dispatch: 派送