Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b89a946
test: make the engine's own test suite bootable
roncodes Oct 9, 2026
3c6ad10
feat(food-trucks): networks show their member stores' trucks; a truck…
roncodes Oct 10, 2026
e60a706
feat(cards): catalogs, trucks and networks render on the shared resou…
roncodes Oct 10, 2026
672665c
fix(truck-card): the online dot comes from the vehicle; a truck witho…
roncodes Oct 10, 2026
4900d9e
fix(truck): a model attribute named store shadowed Ember Data's store…
roncodes Oct 10, 2026
b409f73
feat(context): the console is scoped to a store or a network, with it…
roncodes Oct 10, 2026
41506ec
fix(switcher): the context switcher the design shows, and the store m…
roncodes Oct 10, 2026
56b5f95
fix(switcher): expose the active context through native getters so th…
roncodes Oct 10, 2026
f535fdc
feat(network): overview, stores with status tabs and bulk actions, an…
roncodes Oct 10, 2026
f4b6dbb
fix(i18n): drop the duplicate common.store key that broke the transla…
roncodes Oct 10, 2026
184b5e3
fix(engine): the descriptor initializer runs inside the engine; its h…
roncodes Oct 10, 2026
7182093
feat(network-orders): orders grouped by checkout with status tabs, st…
roncodes Oct 10, 2026
bde3613
fix(network-orders): alias the orders query params so they cannot col…
roncodes Oct 10, 2026
3a5e69c
feat(order-panel): the order details panel answers the dispatcher's f…
roncodes Oct 10, 2026
10f3347
feat(settings): one settings page with a section rail, labelled check…
roncodes Oct 10, 2026
5ab0bc3
feat(catalogs): the catalog editor, a catalog edited as a container o…
roncodes Oct 10, 2026
ba1b4b7
feat(products): the product editor's ten panels become four tabs with…
roncodes Oct 10, 2026
eb11a33
feat(seeders): Mongolian testing network with QPay sandbox gateways, …
roncodes Oct 10, 2026
76a110e
feat(customers): the customer panel answers a support agent's questio…
roncodes Oct 10, 2026
ad11d02
feat(promotions): the promotions hub with counts and tabs, a stepped …
roncodes Oct 10, 2026
7cfed01
feat(trucks): trucks page with status tabs and a side panel; networks…
roncodes Oct 10, 2026
c92484c
feat(products): nested category sidebar with counts and smart groups,…
roncodes Oct 10, 2026
0219d38
fix(products): pass counts, smart groups and the group handler to the…
roncodes Oct 10, 2026
d622404
feat(dashboard): a needs-attention strip counts what the store acts o…
roncodes Oct 10, 2026
3489f83
feat(seeders): Mongolian network and store promotions, segments and a…
roncodes Oct 10, 2026
85fdf71
feat(network-join): invited stores can review, accept or decline a ne…
roncodes Oct 10, 2026
9267ff9
feat(orders): a commerce-first create order form that runs the app's …
roncodes Oct 10, 2026
acf09b6
fix(seeders): re-seeding keeps product, vehicle and food truck ids, s…
roncodes Oct 10, 2026
143aa81
fix(quotes): carts with lines that can no longer be ordered get a car…
roncodes Oct 10, 2026
c87e0c5
fix(qpay): retry an invoice without the receiver email when QPay reje…
roncodes Oct 10, 2026
4ffc99b
revert(qpay): drop the retry-without-email added in ea4d524
roncodes Oct 10, 2026
affbcd3
fix(qpay): follow QPay API v2: paid status and amount before ordering…
roncodes Oct 10, 2026
ac59746
fix(seeders): Mongolian amounts in whole tögrög, a 1,000₮ test item, …
roncodes Oct 10, 2026
d29ee7f
fix(ui): section headers follow the console pattern: no inline subtit…
roncodes Oct 10, 2026
721e913
fix(qpay): exact VAT amounts; floating point truncated e.g. 3190 → 28…
roncodes Oct 10, 2026
c0b1062
fix(checkout): QPay's callback creates the order; the app hears about…
roncodes Oct 10, 2026
f8eb78f
fix(ui): promotions hub tabs sit under each page's section header, pu…
roncodes Oct 10, 2026
3aa5013
fix(ui): Settings is one sidebar entry; the settings page rail holds …
roncodes Oct 10, 2026
a9963be
fix(customers): the customer panel injects intl and notifications; op…
roncodes Oct 10, 2026
9b2e5bc
fix(customers): panel loads insights (storefront service injected), c…
roncodes Oct 10, 2026
38627da
fix(customers): panel insights are tracked and scoped like the order …
roncodes Oct 10, 2026
9266df9
fix(order-panel): wire the Items/Route/Payment/Activity/Customer/Data…
roncodes Oct 10, 2026
f013b88
feat(carts): carts keep what they held: open, checked out and cleared…
roncodes Oct 10, 2026
d0b0629
fix(orders): New order button on the orders page opens the create ord…
roncodes Oct 10, 2026
a83414b
fix(orders): the create order route's customer query param is aliased…
roncodes Oct 10, 2026
996b2ac
fix(customers): New order from the panel passes the aliased for_custo…
roncodes Oct 10, 2026
bc4b92a
fix(networks): the network Stores page drops the category picker row …
roncodes Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addon/adapters/promotion-redemption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './storefront';
6 changes: 3 additions & 3 deletions addon/components/customer-panel.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
@registry="storefront:customer:details"
@resource={{this.customer}}
@controller={{this}}
@headerComponent={{component "customer-panel/panel-header" resource=this.customer actionButtons=this.actionButtons}}
@headerComponent={{component "customer-panel/panel-header" resource=this.customer actionButtons=this.actionButtons insights=this.insights isLoadingInsights=this.loadInsights.isRunning}}
@headerTitle={{or this.customer.name}}
@actionButtons={{this.actionButtons}}
@onPressCancel={{this.onPressCancel}}
@onOverlayReady={{this.setOverlayContext}}
@headerClass="no-bottom-border"
@bodyClass="no-scroll"
@width={{or this.width @width "570px"}}
@width={{or this.width @width "600px"}}
@isResizable={{or this.isResizable @isResizable}}
>
<TabNavigation @tabs={{this.tabs}} @activeTabId={{this.tab.id}} @onTabChange={{this.onTabChanged}} @contentClass="scrollable" @tablistClass="pl-2" as |activeTab|>
{{component activeTab.component customer=this.customer tabOptions=activeTab options=activeTab.componentParams}}
{{component activeTab.component customer=this.customer tabOptions=activeTab options=activeTab.componentParams insights=this.insights onChange=this.reload}}
</TabNavigation>
</Layout::Resource::Panel>
82 changes: 78 additions & 4 deletions addon/components/customer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
import CustomerPanelDetailsComponent from './customer-panel/details';
import CustomerPanelOverviewComponent from './customer-panel/overview';
import CustomerPanelOrdersComponent from './customer-panel/orders';
import CustomerPanelPlacesComponent from './customer-panel/places';
import CustomerPanelActivityComponent from './customer-panel/activity';
import { task } from 'ember-concurrency';
import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback';
import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-context-component-arguments';

Expand Down Expand Up @@ -51,6 +54,10 @@ export default class CustomerPanelComponent extends Component {
* @type {Service}
*/
@service contextPanel;
@service intl;
@service notifications;
@service storefront;
@tracked insights = null;

/**
* The current active tab.
Expand Down Expand Up @@ -80,6 +87,7 @@ export default class CustomerPanelComponent extends Component {
constructor() {
super(...arguments);
this.customer = this.args.customer;
this.loadInsights.perform();

this.tab = this.getTabUsingSlug(this.args.tab);
applyContextComponentArguments(this);
Expand All @@ -95,8 +103,10 @@ export default class CustomerPanelComponent extends Component {
const registeredTabs = this.universe.getMenuItemsFromRegistry('component:customer-panel');

const defaultTabs = [
this.universe._createMenuItem('Details', null, { icon: 'circle-info', component: CustomerPanelDetailsComponent }),
this.universe._createMenuItem('Orders', null, { icon: 'circle-info', component: CustomerPanelOrdersComponent }),
this.universe._createMenuItem(this.intl.t('storefront.customers.panel.tabs.overview'), null, { icon: 'circle-info', component: CustomerPanelOverviewComponent, componentParams: { insights: this.insights, onChange: this.reload } }),
this.universe._createMenuItem(this.intl.t('storefront.customers.panel.tabs.orders'), null, { icon: 'file-invoice-dollar', component: CustomerPanelOrdersComponent }),
this.universe._createMenuItem(this.intl.t('storefront.customers.panel.tabs.places'), null, { icon: 'map-marker-alt', component: CustomerPanelPlacesComponent, componentParams: { onChange: this.reload } }),
this.universe._createMenuItem(this.intl.t('storefront.customers.panel.tabs.activity'), null, { icon: 'wave-square', component: CustomerPanelActivityComponent, componentParams: { insights: this.insights } }),
];

if (isArray(registeredTabs)) {
Expand All @@ -108,14 +118,78 @@ export default class CustomerPanelComponent extends Component {

get actionButtons() {
return [
{
icon: 'plus',
text: this.intl.t('storefront.customers.panel.new-order'),
size: 'sm',
type: 'primary',
onClick: this.newOrder,
permission: 'storefront create order',
},
{
icon: 'pencil',
helpText: 'Edit customer',
text: this.intl.t('common.edit'),
size: 'sm',
onClick: this.onEdit,
permission: 'storefront update customer',
},
{
items: [
{ text: this.intl.t('storefront.customers.panel.copy-id'), icon: 'copy', fn: this.copyId },
{ text: this.intl.t('storefront.customers.panel.copy-email'), icon: 'at', fn: this.copyEmail, disabled: !this.customer?.email },
],
},
];
}

@task *loadInsights() {
const customer = this.customer;

if (!customer?.id) {
return;
}

const scope = this.storefront.isNetworkContext ? { network: this.storefront.getActiveNetwork('public_id') } : { storefront: this.storefront.getActiveStore('public_id') };

try {
this.insights = yield this.fetch.get(`customers/${customer.id}/insights`, scope, { namespace: 'storefront/int/v1' });
} catch {
this.insights = null;
}
}

@action reload() {
return this.loadInsights.perform();
}

@action newOrder() {
try {
return this.hostRouter.transitionTo('console.storefront.orders.index.new', { queryParams: { for_customer: this.customer?.public_id } });
} catch {
return this.hostRouter.transitionTo('console.storefront.orders.index.new');
}
}

@action async copyId() {
return this.copy(this.customer?.public_id);
}

@action async copyEmail() {
return this.copy(this.customer?.email);
}

async copy(value) {
if (!value) {
return;
}

try {
await navigator.clipboard.writeText(value);
this.notifications.success(this.intl.t('storefront.customers.panel.copied', { value }));
} catch {
this.notifications.info(value);
}
}
/**
* Sets the overlay context.
*
Expand Down
23 changes: 23 additions & 0 deletions addon/components/customer-panel/activity.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<div class="p-4 space-y-4" data-test-customer-activity>
<div class="storefront-customer-facts">
<div class="field-info-container">
<div class="field-name">{{t "storefront.customers.panel.first-order"}}</div>
<div class="field-value">{{#if @insights.first_order_at}}{{format-date-fns @insights.first_order_at "d MMM yyyy"}}{{else}}{{t "storefront.customers.panel.never"}}{{/if}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.customers.panel.last-order"}}</div>
<div class="field-value">{{#if @insights.last_order_at}}{{format-date-fns @insights.last_order_at "d MMM yyyy"}} · {{@insights.last_order_public_id}}{{else}}{{t "storefront.customers.panel.never"}}{{/if}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.customers.panel.canceled-orders"}}</div>
<div class="field-value">{{n-a @insights.canceled 0}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.common.created"}}</div>
<div class="field-value">{{n-a (format-date-fns @customer.created_at "d MMM yyyy HH:mm")}}</div>
</div>
</div>
<ContentPanel @title={{t "storefront.customers.panel.notes"}} @open={{true}} @wrapperClass="bordered-top">
<CommentThread @subject={{@customer}} @subjectType="fleet-ops:contact" />
</ContentPanel>
</div>
6 changes: 2 additions & 4 deletions addon/components/customer-panel/orders.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@
<td>
<Badge @status={{order.status}} />
</td>
<td>
<div>
<Storefront::Order::Actions @order={{order}} @onChange={{this.handleOrderChange}} @showText={{true}} />
</div>
<td class="whitespace-nowrap text-right">
<Storefront::Order::Actions @order={{order}} @onChange={{this.handleOrderChange}} @asMenu={{true}} />
</td>
</tr>
{{/each}}
Expand Down
83 changes: 83 additions & 0 deletions addon/components/customer-panel/overview.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<div class="storefront-customer-overview p-4 space-y-4" data-test-customer-overview>
<div class="storefront-customer-facts">
<div class="field-info-container">
<div class="field-name">{{t "storefront.common.email"}}</div>
<div class="field-value">{{#if this.customer.email}}<a href="mailto:{{this.customer.email}}">{{this.customer.email}}</a>{{else}}{{n-a null}}{{/if}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.common.phone"}}</div>
<div class="field-value">{{#if this.customer.phone}}<a href="tel:{{this.customer.phone}}">{{this.customer.phone}}</a>{{else}}{{n-a null}}{{/if}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.customers.panel.preferred-fulfillment"}}</div>
<div class="field-value">{{#if this.insights.preferred_fulfillment}}{{t (concat "storefront.customers.panel.prefers-" this.insights.preferred_fulfillment)}}{{else}}{{n-a null}}{{/if}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.customers.panel.account"}}</div>
<div class="field-value">{{#if this.accountLabel}}{{smart-humanize this.accountLabel}}{{else}}{{t "storefront.customers.panel.guest"}}{{/if}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.common.internal-id"}}</div>
<div class="field-value">{{n-a this.customer.internal_id}}</div>
</div>
<div class="field-info-container">
<div class="field-name">{{t "storefront.common.title"}}</div>
<div class="field-value">{{n-a this.customer.title}}</div>
</div>
</div>

<ContentPanel @title={{t "storefront.customers.panel.saved-places"}} @open={{true}} @pad={{false}} @wrapperClass="bordered-top" @panelBodyClass="p-0">
{{#if this.placeRows.length}}
<div class="storefront-member-list">
{{#each this.placeRows as |row|}}
<div class="storefront-member-row" data-test-customer-place={{row.place.id}}>
<Place::Pill @place={{row.place}} @size="xs" />
<div class="storefront-member-row__identity">
<div class="storefront-member-row__meta">{{n-a row.place.address}}</div>
</div>
{{#if row.isDefault}}<Badge @status="info" @size="xs" @hideStatusDot={{true}} @text={{t "storefront.customers.panel.default"}} />{{/if}}
</div>
{{/each}}
</div>
{{else}}
<div class="storefront-empty-row"><span>{{t "storefront.customers.panel.no-places"}}</span></div>
{{/if}}
</ContentPanel>

{{#if this.showStores}}
<ContentPanel @title={{t "storefront.customers.panel.stores-ordered-from"}} @open={{true}} @pad={{false}} @wrapperClass="bordered-top" @panelBodyClass="p-0">
{{#if this.stores.length}}
<div class="storefront-member-list">
{{#each this.stores as |store|}}
<div class="storefront-member-row">
<div class="storefront-member-row__logo storefront-member-row__logo--icon"><FaIcon @icon="store" /></div>
<div class="storefront-member-row__identity"><div class="storefront-member-row__name">{{n-a store.name store.public_id}}</div></div>
<span class="storefront-member-row__meta">{{t "storefront.networks.orders.items-count" count=store.orders}}</span>
</div>
{{/each}}
</div>
{{else}}
<div class="storefront-empty-row"><span>{{t "storefront.customers.panel.no-orders"}}</span></div>
{{/if}}
</ContentPanel>
{{/if}}

<ContentPanel @title={{t "storefront.customers.panel.recent-orders"}} @open={{true}} @pad={{false}} @wrapperClass="bordered-top" @panelBodyClass="p-0" @isLoading={{this.loadOrders.isRunning}}>
{{#if this.orders.length}}
<div class="storefront-member-list">
{{#each this.orders as |order|}}
<div class="storefront-member-row" data-test-customer-order={{order.public_id}}>
<div class="storefront-member-row__identity">
<a href="javascript:;" class="storefront-member-row__name storefront-order-ref__id--link" {{on "click" (fn this.viewOrder order)}}>{{order.public_id}}</a>
<div class="storefront-member-row__meta">{{order.createdAgo}} · {{if order.meta.is_pickup (t "storefront.common.pickup") (t "storefront.networks.orders.delivery")}}{{#if order.meta.storefront}} · {{order.meta.storefront}}{{/if}}</div>
</div>
<div class="storefront-member-row__amount">{{format-currency (or order.meta.total order.transaction_amount 0) order.meta.currency}}</div>
<Badge @status={{order.status}} @size="xs" />
</div>
{{/each}}
</div>
{{else unless this.loadOrders.isRunning}}
<div class="storefront-empty-row"><span>{{t "storefront.customers.panel.no-orders"}}</span></div>
{{/if}}
</ContentPanel>
</div>
85 changes: 85 additions & 0 deletions addon/components/customer-panel/overview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action, get } from '@ember/object';
import { task } from 'ember-concurrency';

/**
* Everything a support agent needs on one screen: contact facts, saved places,
* the stores ordered from, and the most recent orders.
*/
export default class CustomerPanelOverviewComponent extends Component {
@service fetch;
@service storefront;
@service storefrontOrderActions;
@service notifications;
@tracked orders = [];

constructor() {
super(...arguments);
this.loadOrders.perform();
}

get customer() {
return this.args.customer;
}

get insights() {
return this.args.insights ?? this.args.options?.insights ?? null;
}

get places() {
const places = this.customer?.places;

return places?.toArray?.() ?? Array.from(places ?? []);
}

get defaultPlaceId() {
return this.customer?.place_uuid ?? this.customer?.place?.id ?? null;
}

get placeRows() {
return this.places.map((place) => ({ place, isDefault: place.id === this.defaultPlaceId }));
}

get stores() {
return this.insights?.stores ?? [];
}

get showStores() {
return this.storefront.isNetworkContext || this.stores.length > 1;
}

get accountLabel() {
const status = this.customer?.login_status ?? (this.customer?.user_uuid ? 'app' : null);

return status;
}

@task *loadOrders() {
if (!this.customer?.id) {
return;
}

const scope = this.storefront.isNetworkContext ? { network: this.storefront.getActiveNetwork('public_id') } : { storefront: this.storefront.getActiveStore('public_id') };

try {
const orders = yield this.fetch.get('orders', { ...scope, limit: 5, sort: '-created_at', customer_uuid: this.customer.id }, { namespace: 'storefront/int/v1', normalizeToEmberData: true });
this.orders = orders?.toArray?.() ?? Array.from(orders ?? []);
} catch {
this.orders = [];
}
}

storeNameOf(order) {
return get(order, 'meta.storefront');
}

@action viewOrder(order) {
return this.storefrontOrderActions.viewOrder(order, { onChange: () => this.loadOrders.perform() });
}

@action viewAllOrders() {
return this.args.onViewOrders?.();
}
}
Loading