-
+{{#if this.activeContext}}
+
+
+
+ {{this.activeContext.name}}
+ {{this.contextLabel}}
+
+ {{#if this.badge}}
+ {{this.badge}}
+ {{/if}}
+
{{else if (has-block)}}
{{yield}}
diff --git a/addon/components/store-selector.js b/addon/components/store-selector.js
index bed362ec..745602bb 100644
--- a/addon/components/store-selector.js
+++ b/addon/components/store-selector.js
@@ -3,6 +3,13 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+/**
+ * The context switcher at the top of the sidebar: stores and networks as peers. The
+ * chosen one owns the navigation, the dashboard and the data scope.
+ *
+ * The menu is built in the DOM and positioned fixed so the sidebar's overflow clipping
+ * cannot cut it off.
+ */
export default class StoreSelectorComponent extends Component {
@service intl;
@tracked isOpen = false;
@@ -15,10 +22,44 @@ export default class StoreSelectorComponent extends Component {
return Array.from(this.args.stores ?? []);
}
+ get networks() {
+ return Array.from(this.args.networks ?? []);
+ }
+
get hasStores() {
return this.stores.length > 0;
}
+ get hasNetworks() {
+ return this.networks.length > 0;
+ }
+
+ /**
+ * The networks group appears once there is something to put in it: a user with one
+ * store and no networks sees the plain store menu.
+ */
+ get showNetworks() {
+ return this.hasNetworks || typeof this.args.onCreateNetwork === 'function';
+ }
+
+ get isNetworkContext() {
+ return Boolean(this.args.activeNetwork);
+ }
+
+ get activeContext() {
+ return this.args.activeNetwork ?? this.args.activeStore;
+ }
+
+ get contextLabel() {
+ return this.intl.t(`storefront.component.store-selector.${this.isNetworkContext ? 'network' : 'store'}`);
+ }
+
+ get badge() {
+ const badge = Number(this.args.badge ?? 0);
+
+ return Number.isFinite(badge) && badge > 0 ? badge : null;
+ }
+
willDestroy() {
super.willDestroy(...arguments);
this.close();
@@ -64,68 +105,161 @@ export default class StoreSelectorComponent extends Component {
}
createMenuElement() {
+ const t = (key) => this.intl.t(`storefront.component.store-selector.${key}`);
const menu = document.createElement('div');
menu.setAttribute('role', 'menu');
- menu.className = 'store-selector-dropdown-menu next-dd-menu py-1';
- menu.style.position = 'fixed';
- menu.style.zIndex = '900';
- menu.style.margin = '0';
- menu.style.height = 'auto';
- menu.style.minHeight = '0';
- menu.style.maxHeight = 'calc(100vh - 16px)';
- menu.style.overflowX = 'hidden';
- menu.style.overflowY = 'visible';
-
- const storeList = document.createElement('div');
- storeList.setAttribute('role', 'group');
- storeList.className = 'px-1';
- storeList.style.maxHeight = '18rem';
- storeList.style.overflowY = 'auto';
+ menu.className = 'store-selector-dropdown-menu storefront-switcher-menu';
+ menu.setAttribute('data-theme', document.body.dataset.theme ?? 'light');
+
+ const storeList = this.createGroup('stores', this.showNetworks ? t('stores') : null);
if (this.hasStores) {
this.stores.forEach((store) => {
- storeList.appendChild(this.createMenuItem(store?.name || '-', () => this.onSwitchStore(store)));
+ storeList.appendChild(
+ this.createMenuItem({
+ label: store?.name || '—',
+ meta: store?.currency ?? null,
+ icon: 'store',
+ tone: 'store',
+ isActive: !this.isNetworkContext && store?.id === this.args.activeStore?.id,
+ onClick: () => this.onSwitchStore(store),
+ })
+ );
});
} else {
- const emptyItem = document.createElement('div');
- emptyItem.className = 'next-dd-item';
- emptyItem.setAttribute('role', 'menuitem');
- emptyItem.textContent = this.intl.t('storefront.component.store-selector.no-stores');
- storeList.appendChild(emptyItem);
+ storeList.appendChild(this.createEmptyItem(t('no-stores')));
}
- const footer = document.createElement('div');
- footer.className = 'px-1';
+ menu.appendChild(storeList);
+
+ if (this.showNetworks) {
+ const networkList = this.createGroup('networks', t('networks'));
+
+ if (this.hasNetworks) {
+ this.networks.forEach((network) => {
+ const storesCount = Number(network?.stores_count);
+ const count = Number.isFinite(storesCount) && storesCount > 0 ? storesCount : network?.stores?.length > 0 ? network.stores.length : null;
+
+ networkList.appendChild(
+ this.createMenuItem({
+ label: network?.name || '—',
+ meta: count === null ? null : this.intl.t('storefront.networks.card.stores-count', { count }),
+ icon: 'network-wired',
+ tone: 'network',
+ isActive: this.isNetworkContext && network?.id === this.args.activeNetwork?.id,
+ onClick: () => this.onSwitchNetwork(network),
+ })
+ );
+ });
+ } else {
+ networkList.appendChild(this.createEmptyItem(t('no-networks')));
+ }
+
+ menu.appendChild(networkList);
+ }
+
+ const footer = this.createGroup('actions', null);
+ footer.classList.add('storefront-switcher-menu__footer');
- const separator = document.createElement('div');
- separator.className = 'next-dd-menu-seperator';
+ if (typeof this.args.onCreateStore === 'function') {
+ footer.appendChild(this.createMenuItem({ label: t('new-store'), icon: 'plus', tone: 'muted', onClick: () => this.onCreateStore() }));
+ }
- const footerGroup = document.createElement('div');
- footerGroup.setAttribute('role', 'group');
- footerGroup.className = 'px-1';
- footerGroup.appendChild(this.createMenuItem(this.intl.t('storefront.component.store-selector.create-storefront'), () => this.onCreateStore()));
+ if (typeof this.args.onCreateNetwork === 'function') {
+ footer.appendChild(this.createMenuItem({ label: t('new-network'), icon: 'plus', tone: 'muted', onClick: () => this.onCreateNetwork() }));
+ }
- footer.append(separator, footerGroup);
- menu.append(storeList, footer);
+ if (footer.childElementCount) {
+ menu.appendChild(footer);
+ }
return menu;
}
- createMenuItem(label, callback) {
- const item = document.createElement('a');
- item.href = 'javascript:;';
- item.className = 'next-dd-item';
+ createGroup(name, heading) {
+ const group = document.createElement('div');
+ group.setAttribute('role', 'group');
+ group.setAttribute(`data-test-store-selector-${name}`, '');
+ group.className = 'storefront-switcher-menu__group';
+
+ if (heading) {
+ const head = document.createElement('div');
+ head.className = 'storefront-switcher-menu__head';
+ head.setAttribute('role', 'presentation');
+ head.textContent = heading;
+ group.appendChild(head);
+ }
+
+ return group;
+ }
+
+ createMenuItem({ label, meta = null, icon, tone = 'store', isActive = false, onClick }) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = `storefront-switcher-menu__item${isActive ? ' is-active' : ''}`;
item.setAttribute('role', 'menuitem');
- item.textContent = label;
+
+ if (isActive) {
+ item.setAttribute('aria-current', 'true');
+ }
+
+ const tile = document.createElement('span');
+ tile.className = `storefront-switcher-tile storefront-switcher-tile--${tone}`;
+ tile.innerHTML = this.iconMarkup(icon);
+
+ const text = document.createElement('span');
+ text.className = 'storefront-switcher-menu__label';
+ text.textContent = label;
+
+ item.append(tile, text);
+
+ if (meta) {
+ const metaElement = document.createElement('span');
+ metaElement.className = 'storefront-switcher-menu__meta';
+ metaElement.textContent = meta;
+ item.appendChild(metaElement);
+ }
+
+ if (isActive) {
+ const check = document.createElement('span');
+ check.className = 'storefront-switcher-menu__check';
+ check.innerHTML = this.iconMarkup('check');
+ item.appendChild(check);
+ }
+
item.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
- callback();
+ onClick();
});
return item;
}
+ createEmptyItem(text) {
+ const item = document.createElement('div');
+ item.className = 'storefront-switcher-menu__empty';
+ item.setAttribute('role', 'presentation');
+ item.textContent = text;
+
+ return item;
+ }
+
+ /**
+ * Inline SVG for the few icons the menu needs; the menu lives outside the component tree,
+ * so it cannot use the FaIcon component.
+ */
+ iconMarkup(name) {
+ const paths = {
+ store: '
',
+ 'network-wired': '
',
+ plus: '
',
+ check: '
',
+ };
+
+ return `
${paths[name] ?? ''} `;
+ }
+
addListeners() {
this.addManagedListener(document, 'mousedown', this.handleDocumentMouseDown, true);
this.addManagedListener(document, 'keydown', this.handleDocumentKeydown);
@@ -175,16 +309,16 @@ export default class StoreSelectorComponent extends Component {
const rect = this.triggerElement.getBoundingClientRect();
const viewportPadding = 8;
- const width = Math.max(rect.width, 220);
+ const width = Math.max(rect.width, 260);
const maxLeft = window.innerWidth - width - viewportPadding;
const left = Math.max(viewportPadding, Math.min(rect.left, maxLeft));
- let top = rect.bottom + 4;
+ let top = rect.bottom + 6;
this.menuElement.style.width = `${width}px`;
const menuHeight = this.menuElement.offsetHeight;
- if (top + menuHeight > window.innerHeight - viewportPadding && rect.top - menuHeight - 4 > viewportPadding) {
- top = rect.top - menuHeight - 4;
+ if (top + menuHeight > window.innerHeight - viewportPadding && rect.top - menuHeight - 6 > viewportPadding) {
+ top = rect.top - menuHeight - 6;
}
this.menuElement.style.left = `${left}px`;
@@ -192,22 +326,22 @@ export default class StoreSelectorComponent extends Component {
};
@action onSwitchStore(store) {
- const { onSwitchStore } = this.args;
-
- if (typeof onSwitchStore === 'function') {
- onSwitchStore(store);
- }
+ this.args.onSwitchStore?.(store);
+ this.close();
+ }
+ @action onSwitchNetwork(network) {
+ this.args.onSwitchNetwork?.(network);
this.close();
}
@action onCreateStore() {
- const { onCreateStore } = this.args;
-
- if (typeof onCreateStore === 'function') {
- onCreateStore();
- }
+ this.args.onCreateStore?.();
+ this.close();
+ }
+ @action onCreateNetwork() {
+ this.args.onCreateNetwork?.();
this.close();
}
}
diff --git a/addon/components/storefront/catalog/card.hbs b/addon/components/storefront/catalog/card.hbs
new file mode 100644
index 00000000..e9240f5e
--- /dev/null
+++ b/addon/components/storefront/catalog/card.hbs
@@ -0,0 +1,70 @@
+
+
+
+ {{#if this.isEmpty}}
+ {{t "storefront.catalogs.card.no-categories"}}
+ {{else}}
+
+ {{#each this.categorySummaries as |category|}}
+ {{category.name}} {{category.count}}
+ {{/each}}
+
+ {{/if}}
+
+ {{#if this.subjects.length}}
+
+ {{#each this.subjects as |subject|}}
+
+
+
+ {{subject.name}}
+
+ {{/each}}
+
+ {{else}}
+ {{t "storefront.catalogs.card.store-only"}}
+ {{/if}}
+
+
+
diff --git a/addon/components/storefront/catalog/card.js b/addon/components/storefront/catalog/card.js
new file mode 100644
index 00000000..3a5408f5
--- /dev/null
+++ b/addon/components/storefront/catalog/card.js
@@ -0,0 +1,54 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+
+/**
+ * A catalog on the shared card anatomy: name and status in the header, the
+ * categories it contains with their product counts in the body, its hours
+ * strip, and the status, actions and counts in the footer.
+ */
+export default class StorefrontCatalogCardComponent extends Component {
+ get catalog() {
+ return this.args.catalog ?? this.args.resource;
+ }
+
+ get categories() {
+ const categories = get(this.catalog, 'categories');
+
+ return categories ? categories.slice() : [];
+ }
+
+ get categorySummaries() {
+ return this.categories.map((category) => ({
+ name: get(category, 'name'),
+ count: (get(category, 'products') ?? []).length,
+ }));
+ }
+
+ get productsCount() {
+ return this.categorySummaries.reduce((total, category) => total + category.count, 0);
+ }
+
+ get hours() {
+ const hours = get(this.catalog, 'hours');
+
+ return hours ? hours.slice() : [];
+ }
+
+ get subjects() {
+ const subjects = get(this.catalog, 'subjects');
+
+ return Array.isArray(subjects) ? subjects : [];
+ }
+
+ get status() {
+ return get(this.catalog, 'status') ?? 'draft';
+ }
+
+ get isDraft() {
+ return this.status === 'draft';
+ }
+
+ get isEmpty() {
+ return this.categories.length === 0;
+ }
+}
diff --git a/addon/components/storefront/hours-strip.hbs b/addon/components/storefront/hours-strip.hbs
new file mode 100644
index 00000000..e9b01a40
--- /dev/null
+++ b/addon/components/storefront/hours-strip.hbs
@@ -0,0 +1,8 @@
+
+ {{#each this.days as |day|}}
+
+ {{day.label}}
+
+
+ {{/each}}
+
diff --git a/addon/components/storefront/hours-strip.js b/addon/components/storefront/hours-strip.js
new file mode 100644
index 00000000..8f7e3c41
--- /dev/null
+++ b/addon/components/storefront/hours-strip.js
@@ -0,0 +1,34 @@
+import Component from '@glimmer/component';
+
+const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
+
+/**
+ * A seven-cell strip that shows which days a schedule covers, from a list of
+ * hour records (`day_of_week`, `start`, `end`) the way catalog, store and
+ * product hours are stored. Each cell carries the day's hours as its title.
+ */
+export default class StorefrontHoursStripComponent extends Component {
+ get days() {
+ const hours = (this.args.hours ?? []).filter(Boolean);
+
+ return DAYS.map((day) => {
+ const matches = hours.filter((hour) =>
+ String(hour.day_of_week ?? '')
+ .toLowerCase()
+ .startsWith(day.slice(0, 3))
+ );
+ const ranges = matches.map((hour) => [hour.start, hour.end].filter(Boolean).join(' to ')).filter(Boolean);
+
+ return {
+ key: day,
+ label: day.charAt(0).toUpperCase() + day.slice(1, 3),
+ open: matches.length > 0,
+ title: ranges.length ? `${day.charAt(0).toUpperCase() + day.slice(1)}: ${ranges.join(', ')}` : `${day.charAt(0).toUpperCase() + day.slice(1)}: closed`,
+ };
+ });
+ }
+
+ get openDays() {
+ return this.days.filter((day) => day.open).length;
+ }
+}
diff --git a/addon/components/storefront/network/card.hbs b/addon/components/storefront/network/card.hbs
new file mode 100644
index 00000000..c0350f59
--- /dev/null
+++ b/addon/components/storefront/network/card.hbs
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
diff --git a/addon/components/storefront/network/card.js b/addon/components/storefront/network/card.js
new file mode 100644
index 00000000..42e326d1
--- /dev/null
+++ b/addon/components/storefront/network/card.js
@@ -0,0 +1,29 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+
+/**
+ * A network on the shared card anatomy: name and online state in the header,
+ * the logo on its backdrop as media, and the member count, currency and
+ * actions in the footer.
+ */
+export default class StorefrontNetworkCardComponent extends Component {
+ get network() {
+ return this.args.network ?? this.args.resource;
+ }
+
+ get isOnline() {
+ return Boolean(get(this.network, 'online'));
+ }
+
+ get storesCount() {
+ const count = get(this.network, 'stores_count');
+
+ if (typeof count === 'number') {
+ return count;
+ }
+
+ const stores = get(this.network, 'stores');
+
+ return stores ? stores.length : 0;
+ }
+}
diff --git a/addon/components/storefront/network/invitation-banner.hbs b/addon/components/storefront/network/invitation-banner.hbs
new file mode 100644
index 00000000..a8ff1095
--- /dev/null
+++ b/addon/components/storefront/network/invitation-banner.hbs
@@ -0,0 +1,14 @@
+{{#if this.invitations.length}}
+
+ {{#each this.invitations as |invitation|}}
+
+
+
+
{{t "storefront.networks.join.banner-title" network=invitation.network.name}}
+
{{#if invitation.expires_at}}{{t "storefront.networks.join.expires" when=(format-date-fns invitation.expires_at "d MMM")}} · {{/if}}{{#if invitation.message}}“{{invitation.message}}”{{/if}}
+
+
+
+ {{/each}}
+
+{{/if}}
diff --git a/addon/components/storefront/network/invitation-banner.js b/addon/components/storefront/network/invitation-banner.js
new file mode 100644
index 00000000..bce1031e
--- /dev/null
+++ b/addon/components/storefront/network/invitation-banner.js
@@ -0,0 +1,19 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+/**
+ * The store's open network invitations, shown on its dashboard until each is answered.
+ */
+export default class StorefrontNetworkInvitationBannerComponent extends Component {
+ @service storefront;
+ @service hostRouter;
+
+ get invitations() {
+ return this.storefront.pendingInvitations ?? [];
+ }
+
+ @action review(invitation) {
+ return this.hostRouter.transitionTo('console.storefront.networks.join', invitation.uri);
+ }
+}
diff --git a/addon/components/storefront/network/invite-panel.hbs b/addon/components/storefront/network/invite-panel.hbs
new file mode 100644
index 00000000..8b5b5db1
--- /dev/null
+++ b/addon/components/storefront/network/invite-panel.hbs
@@ -0,0 +1,83 @@
+
+
+
+ {{t "storefront.networks.invitations.panel-lede"}}
+
+
+ {{#each this.tabItems as |tab|}}
+
+ {{tab.label}}
+
+ {{/each}}
+
+
+ {{#if (eq this.activeTab "email")}}
+
+
+
+ {{tag}}
+
+ {{#if this.flaggedRecipients.length}}
+ {{t "storefront.networks.invitations.already-flagged" count=this.flaggedRecipients.length}}
+ {{/if}}
+ {{#if this.invalidRecipients.length}}
+ {{t "storefront.networks.invitations.invalid-flagged" count=this.invalidRecipients.length}}
+ {{/if}}
+
+
+
+
+ {{category.name}}
+
+
+
+
+
+ {{option.label}}
+
+
+
+
+
+
+
+
+
+
+
+ {{else if (eq this.activeTab "link")}}
+
+
+ {{#if this.shareableLinkEnabled}}
+
+
+
+
{{t "storefront.networks.invitations.link-hint"}}
+ {{/if}}
+
+
+ {{else}}
+
+
+
+
+
+
+
+
+ {{category.name}}
+
+
+
+
+ {{/if}}
+
+
diff --git a/addon/components/storefront/network/invite-panel.js b/addon/components/storefront/network/invite-panel.js
new file mode 100644
index 00000000..20731e1a
--- /dev/null
+++ b/addon/components/storefront/network/invite-panel.js
@@ -0,0 +1,220 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import isEmail from '@fleetbase/ember-core/utils/is-email';
+import createShareableLink from '../../../utils/create-shareable-link';
+
+/**
+ * Invite stores to a network: by email, with a shareable link, or by adding a store the
+ * user already administers. Replaces the share-network and add-stores-to-network modals.
+ */
+export default class StorefrontNetworkInvitePanelComponent extends Component {
+ @service store;
+ @service fetch;
+ @service intl;
+ @service notifications;
+ @service currentUser;
+ @tracked activeTab = this.args.activeTab ?? 'email';
+ @tracked recipients = [];
+ @tracked category = null;
+ @tracked expiresInDays = 14;
+ @tracked message = '';
+ @tracked requireApproval = false;
+ @tracked categories = [];
+ @tracked members = [];
+ @tracked ownStores = [];
+ @tracked selectedStores = [];
+ @tracked ownStoreCategory = null;
+
+ tabs = ['email', 'link', 'own'];
+ expiryOptions = [
+ { value: 14, labelKey: 'storefront.networks.invitations.expires-14' },
+ { value: 7, labelKey: 'storefront.networks.invitations.expires-7' },
+ { value: 30, labelKey: 'storefront.networks.invitations.expires-30' },
+ { value: 0, labelKey: 'storefront.networks.invitations.expires-never' },
+ ];
+
+ constructor() {
+ super(...arguments);
+ this.load.perform();
+ }
+
+ get network() {
+ return this.args.network;
+ }
+
+ get tabItems() {
+ return this.tabs.map((id) => ({ id, label: this.intl.t(`storefront.networks.invitations.tab-${id}`), isActive: id === this.activeTab }));
+ }
+
+ get expiryItems() {
+ return this.expiryOptions.map((option) => ({ ...option, label: this.intl.t(option.labelKey) }));
+ }
+
+ get selectedExpiry() {
+ return this.expiryItems.find((option) => option.value === this.expiresInDays) ?? this.expiryItems[0];
+ }
+
+ get validRecipients() {
+ return this.recipients.filter((email) => isEmail(email));
+ }
+
+ get invalidRecipients() {
+ return this.recipients.filter((email) => !isEmail(email));
+ }
+
+ get knownEmails() {
+ const memberEmails = this.members.map((store) => store.email).filter(Boolean);
+ const invitedEmails = (this.network.invitations ?? []).filter((invitation) => invitation.status === 'pending').map((invitation) => invitation.email);
+
+ return new Set([...memberEmails, ...invitedEmails].map((email) => String(email).toLowerCase()));
+ }
+
+ get flaggedRecipients() {
+ return this.recipients.filter((email) => this.knownEmails.has(String(email).toLowerCase()));
+ }
+
+ get sendableRecipients() {
+ return this.validRecipients.filter((email) => !this.knownEmails.has(String(email).toLowerCase()));
+ }
+
+ get canSend() {
+ return this.sendableRecipients.length > 0 && !this.send.isRunning;
+ }
+
+ get shareableLink() {
+ return createShareableLink(`join/network/${this.network.public_id}`);
+ }
+
+ get shareableLinkEnabled() {
+ return Boolean(this.network.options?.shareable_link_enabled);
+ }
+
+ get availableOwnStores() {
+ const memberIds = new Set(this.members.map((store) => store.id));
+
+ return this.ownStores.filter((store) => !memberIds.has(store.id));
+ }
+
+ get canAddOwnStores() {
+ return this.selectedStores.length > 0 && !this.addOwnStores.isRunning;
+ }
+
+ @task *load() {
+ try {
+ const [categories, members, ownStores] = yield Promise.all([
+ this.store.query('category', { owner_uuid: this.network.id, for: 'storefront_network', parents_only: true }),
+ this.store.query('store', { network: this.network.id }),
+ this.store.query('store', { limit: 300 }),
+ ]);
+ this.categories = categories?.toArray?.() ?? Array.from(categories ?? []);
+ this.members = members?.toArray?.() ?? Array.from(members ?? []);
+ this.ownStores = ownStores?.toArray?.() ?? Array.from(ownStores ?? []);
+ if (!this.network.invitations) {
+ yield this.network.loadInvitations();
+ }
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @task *send() {
+ try {
+ yield this.network.sendInvites({
+ recipients: this.sendableRecipients,
+ category_uuid: this.category?.id ?? null,
+ expires_in_days: this.expiresInDays || null,
+ message: this.message || null,
+ require_approval: this.requireApproval,
+ });
+ this.notifications.success(this.intl.t('storefront.networks.invitations.sent', { count: this.sendableRecipients.length }));
+ this.recipients = [];
+ this.message = '';
+ yield this.network.loadInvitations();
+ this.args.onSent?.();
+ this.args.onClose?.();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @task *addOwnStores() {
+ try {
+ yield this.network.addStores(this.selectedStores);
+ if (this.ownStoreCategory) {
+ for (const store of this.selectedStores) {
+ yield this.fetch.post(`networks/${this.network.id}/set-store-category`, { store: store.id, category: this.ownStoreCategory.id }, { namespace: 'storefront/int/v1' });
+ }
+ }
+ this.notifications.success(this.intl.t('storefront.networks.invitations.own-stores-added', { count: this.selectedStores.length }));
+ this.selectedStores = [];
+ this.args.onSent?.();
+ this.args.onClose?.();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @task *toggleShareableLink(enabled) {
+ try {
+ this.network.set('options', { ...(this.network.options ?? {}), shareable_link_enabled: enabled });
+ yield this.network.save();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action selectTab(tab) {
+ this.activeTab = tab.id ?? tab;
+ }
+
+ @action addRecipient(value) {
+ const emails = String(value ?? '')
+ .split(/[\s,;]+/)
+ .map((email) => email.trim())
+ .filter(Boolean);
+ const next = [...this.recipients];
+
+ emails.forEach((email) => {
+ if (!next.includes(email)) {
+ next.push(email);
+ }
+ });
+
+ this.recipients = next;
+ }
+
+ @action removeRecipient(index) {
+ this.recipients = this.recipients.filter((_, i) => i !== index);
+ }
+
+ @action setCategory(category) {
+ this.category = category;
+ }
+
+ @action setOwnStoreCategory(category) {
+ this.ownStoreCategory = category;
+ }
+
+ @action setExpiry(option) {
+ this.expiresInDays = option?.value ?? 0;
+ }
+
+ @action setMessage(event) {
+ this.message = event.target.value;
+ }
+
+ @action setRequireApproval(enabled) {
+ this.requireApproval = enabled;
+ }
+
+ @action setSelectedStores(stores) {
+ this.selectedStores = Array.from(stores ?? []);
+ }
+
+ @action close() {
+ this.args.onClose?.();
+ }
+}
diff --git a/addon/components/storefront/network/orders/cell/actions.hbs b/addon/components/storefront/network/orders/cell/actions.hbs
new file mode 100644
index 00000000..a1f9211d
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/actions.hbs
@@ -0,0 +1,25 @@
+
+ {{#if @row.isGroup}}
+ {{t "storefront.networks.orders.expand-hint"}}
+ {{else}}
+ {{#if this.primary}}
+
+ {{/if}}
+
+
+
+ {{/if}}
+
diff --git a/addon/components/storefront/network/orders/cell/actions.js b/addon/components/storefront/network/orders/cell/actions.js
new file mode 100644
index 00000000..b5ad6334
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/actions.js
@@ -0,0 +1,44 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+/**
+ * The next action for an order promoted to a button, the rest behind an ellipsis.
+ * A checkout group has no action of its own; its orders do.
+ */
+export default class StorefrontNetworkOrdersCellActionsComponent extends Component {
+ @service storefrontOrderActions;
+
+ get items() {
+ const { row, column } = this.args;
+
+ if (row?.isGroup) {
+ return [];
+ }
+
+ return this.storefrontOrderActions.actionItemsFor(row, column?.onChange);
+ }
+
+ get primary() {
+ return this.items.find((item) => item.id === 'perform-workflow-action') ?? null;
+ }
+
+ get secondary() {
+ const primary = this.primary;
+ const rest = this.items.filter((item) => item !== primary);
+ const { row, column } = this.args;
+
+ return [
+ { text: 'View order', icon: 'eye', fn: () => column?.onView?.(row) },
+ ...rest,
+ { separator: true },
+ { text: 'Delete order', icon: 'trash', class: 'text-danger', fn: () => column?.onDelete?.(row) },
+ ];
+ }
+
+ @action run(item) {
+ if (typeof item?.fn === 'function') {
+ item.fn();
+ }
+ }
+}
diff --git a/addon/components/storefront/network/orders/cell/driver.hbs b/addon/components/storefront/network/orders/cell/driver.hbs
new file mode 100644
index 00000000..18595d37
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/driver.hbs
@@ -0,0 +1,16 @@
+
+ {{#if this.driver}}
+
+ {{#if (and @row.isGroup (gt @row.orderCount 1))}}
+ {{t "storefront.networks.orders.driver-shared" count=@row.orderCount}}
+ {{/if}}
+ {{else if this.isPickup}}
+ {{t "storefront.networks.orders.no-driver-needed"}}
+ {{else if (gt this.driverCount 1)}}
+ {{t "storefront.networks.orders.drivers-count" count=this.driverCount}}
+ {{else if this.canAssign}}
+
+ {{else}}
+ {{t "storefront.networks.orders.unassigned"}}
+ {{/if}}
+
diff --git a/addon/components/storefront/network/orders/cell/driver.js b/addon/components/storefront/network/orders/cell/driver.js
new file mode 100644
index 00000000..c0420634
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/driver.js
@@ -0,0 +1,34 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry';
+import { rankOf } from '../../../../../utils/order-groups';
+
+export default class StorefrontNetworkOrdersCellDriverComponent extends Component {
+ get row() {
+ return this.args.row;
+ }
+
+ get driver() {
+ if (this.row?.isGroup) {
+ return this.row.driver;
+ }
+
+ return relationValue(this.row, 'driver_assigned') ?? null;
+ }
+
+ get isPickup() {
+ return Boolean(this.row?.isGroup ? this.row.is_pickup : get(this.row, 'meta.is_pickup'));
+ }
+
+ get driverCount() {
+ return this.row?.isGroup ? this.row.driverCount : this.driver ? 1 : 0;
+ }
+
+ get isTerminal() {
+ return this.row?.isGroup ? this.row.allTerminal : rankOf(this.row?.status) >= 4;
+ }
+
+ get canAssign() {
+ return !this.row?.isGroup && !this.isPickup && !this.driver && !this.isTerminal && typeof this.args.column?.onAssign === 'function';
+ }
+}
diff --git a/addon/components/storefront/network/orders/cell/fulfillment.hbs b/addon/components/storefront/network/orders/cell/fulfillment.hbs
new file mode 100644
index 00000000..b170ef10
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/fulfillment.hbs
@@ -0,0 +1,6 @@
+{{#let (if @row.isGroup @row.is_pickup @row.meta.is_pickup) as |isPickup|}}
+
+
+ {{if isPickup (t "storefront.common.pickup") (t "storefront.networks.orders.delivery")}}
+
+{{/let}}
diff --git a/addon/components/storefront/network/orders/cell/placed.hbs b/addon/components/storefront/network/orders/cell/placed.hbs
new file mode 100644
index 00000000..cfcd22f6
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/placed.hbs
@@ -0,0 +1 @@
+
{{if @row.isGroup @row.createdAtShort @row.createdAtShort}}
diff --git a/addon/components/storefront/network/orders/cell/reference.hbs b/addon/components/storefront/network/orders/cell/reference.hbs
new file mode 100644
index 00000000..6217e173
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/reference.hbs
@@ -0,0 +1,13 @@
+
+ {{#if @row.isGroup}}
+
{{@row.id}}
+
{{t "storefront.networks.orders.group-summary" orders=@row.orderCount items=@row.itemCount}}
+ {{else}}
+
{{@row.public_id}}
+ {{#if @row.meta.checkout_id}}
+
{{t "storefront.networks.orders.part-of" checkout=@row.meta.checkout_id}}
+ {{else if @row.item_count}}
+
{{t "storefront.networks.orders.items-count" count=@row.item_count}}
+ {{/if}}
+ {{/if}}
+
diff --git a/addon/components/storefront/network/orders/cell/status.hbs b/addon/components/storefront/network/orders/cell/status.hbs
new file mode 100644
index 00000000..79def0bd
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/status.hbs
@@ -0,0 +1,7 @@
+
+ {{#if @row.isGroup}}
+
+ {{else}}
+
+ {{/if}}
+
diff --git a/addon/components/storefront/network/orders/cell/stores.hbs b/addon/components/storefront/network/orders/cell/stores.hbs
new file mode 100644
index 00000000..1214a70b
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/stores.hbs
@@ -0,0 +1,14 @@
+
+ {{#each this.visible as |entry|}}
+ {{#if entry.store}}
+
+ {{else}}
+ {{entry.name}}
+ {{/if}}
+ {{else}}
+ {{t "storefront.networks.orders.no-store"}}
+ {{/each}}
+ {{#if this.overflow}}
+ +{{this.overflow}}
+ {{/if}}
+
diff --git a/addon/components/storefront/network/orders/cell/stores.js b/addon/components/storefront/network/orders/cell/stores.js
new file mode 100644
index 00000000..5b06f590
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/stores.js
@@ -0,0 +1,33 @@
+import Component from '@glimmer/component';
+import { orderStoreId, orderStoreName } from '../../../../../utils/order-groups';
+
+const MAX_VISIBLE = 4;
+
+/**
+ * The stores behind a row: one for an order, up to four tiles plus an overflow count for a checkout group.
+ */
+export default class StorefrontNetworkOrdersCellStoresComponent extends Component {
+ get entries() {
+ const { row, column } = this.args;
+ const storesById = column?.storesById ?? {};
+ const ids = row?.isGroup ? (row.storeIds ?? []) : [orderStoreId(row)].filter(Boolean);
+ const names = row?.isGroup ? (row.storeNames ?? []) : [orderStoreName(row)].filter(Boolean);
+
+ return ids.map((id, index) => ({ id, store: storesById[id] ?? null, name: storesById[id]?.name ?? names[index] ?? names[0] ?? id }));
+ }
+
+ get visible() {
+ return this.entries.slice(0, MAX_VISIBLE);
+ }
+
+ get overflow() {
+ return Math.max(0, this.entries.length - MAX_VISIBLE);
+ }
+
+ get overflowTitle() {
+ return this.entries
+ .slice(MAX_VISIBLE)
+ .map((entry) => entry.name)
+ .join(', ');
+ }
+}
diff --git a/addon/components/storefront/network/orders/cell/total.hbs b/addon/components/storefront/network/orders/cell/total.hbs
new file mode 100644
index 00000000..7f44032e
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/total.hbs
@@ -0,0 +1,14 @@
+
+ {{format-currency this.total this.currency}}
+ {{#if this.tip}}
+
+ {{#if (eq this.tipLabel "split")}}
+ {{t "storefront.networks.orders.tip-split" tip=(format-currency this.tip this.currency) count=@row.orderCount}}
+ {{else if (eq this.tipLabel "shared")}}
+ {{t "storefront.networks.orders.tip-shared" tip=(format-currency this.tip this.currency)}}
+ {{else}}
+ {{t "storefront.networks.orders.tip-to-store" tip=(format-currency this.tip this.currency)}}
+ {{/if}}
+
+ {{/if}}
+
diff --git a/addon/components/storefront/network/orders/cell/total.js b/addon/components/storefront/network/orders/cell/total.js
new file mode 100644
index 00000000..6f6769a4
--- /dev/null
+++ b/addon/components/storefront/network/orders/cell/total.js
@@ -0,0 +1,42 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+
+function number(value) {
+ const parsed = parseFloat(value);
+
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+export default class StorefrontNetworkOrdersCellTotalComponent extends Component {
+ get total() {
+ const { row } = this.args;
+
+ return row?.isGroup ? row.total : number(get(row, 'meta.total') ?? row?.transaction_amount);
+ }
+
+ get tip() {
+ const { row } = this.args;
+
+ return row?.isGroup ? row.tip : number(get(row, 'meta.tip')) + number(get(row, 'meta.delivery_tip'));
+ }
+
+ get currency() {
+ const { row } = this.args;
+
+ return (row?.isGroup ? row.currency : get(row, 'meta.currency') ?? row?.currency) ?? 'USD';
+ }
+
+ get tipLabel() {
+ if (!this.tip) {
+ return null;
+ }
+
+ const { row } = this.args;
+
+ if (row?.isGroup) {
+ return row.orderCount > 1 ? 'split' : 'network';
+ }
+
+ return get(row, 'meta.checkout_id') ? 'shared' : 'store';
+ }
+}
diff --git a/addon/components/storefront/order/actions.hbs b/addon/components/storefront/order/actions.hbs
index b6204853..d95d11f2 100644
--- a/addon/components/storefront/order/actions.hbs
+++ b/addon/components/storefront/order/actions.hbs
@@ -1,13 +1,30 @@
-
- {{#each this.items as |item|}}
-
- {{/each}}
-
+{{#if @asMenu}}
+ {{#if this.items.length}}
+
+
+
+ {{/if}}
+{{else}}
+
+ {{#each this.items as |item|}}
+
+ {{/each}}
+
+{{/if}}
diff --git a/addon/components/storefront/order/details/checkout-siblings.hbs b/addon/components/storefront/order/details/checkout-siblings.hbs
new file mode 100644
index 00000000..eb329e49
--- /dev/null
+++ b/addon/components/storefront/order/details/checkout-siblings.hbs
@@ -0,0 +1,18 @@
+
+ {{#if this.siblings.length}}
+
+ {{#each this.siblings as |order|}}
+
+
+
{{format-currency order.meta.total order.meta.currency}}
+
+
+ {{/each}}
+
+ {{else}}
+ {{t "storefront.order.no-other-orders"}}
+ {{/if}}
+
diff --git a/addon/components/storefront/order/details/checkout-siblings.js b/addon/components/storefront/order/details/checkout-siblings.js
new file mode 100644
index 00000000..472660f8
--- /dev/null
+++ b/addon/components/storefront/order/details/checkout-siblings.js
@@ -0,0 +1,36 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+
+/**
+ * The other orders placed in the same checkout: a multi-store purchase creates one per store.
+ */
+export default class StorefrontOrderDetailsCheckoutSiblingsComponent extends Component {
+ @service fetch;
+ @service storefrontOrderActions;
+ @tracked siblings = [];
+
+ constructor() {
+ super(...arguments);
+ this.load.perform();
+ }
+
+ get checkoutId() {
+ return this.args.resource?.meta?.checkout_id;
+ }
+
+ @task *load() {
+ if (!this.checkoutId) {
+ return;
+ }
+
+ try {
+ const orders = yield this.fetch.get('orders', { query: this.checkoutId, limit: 25 }, { namespace: 'storefront/int/v1', normalizeToEmberData: true, normalizeModelType: 'order' });
+ const list = orders?.toArray?.() ?? Array.from(orders ?? []);
+ this.siblings = list.filter((order) => order.meta?.checkout_id === this.checkoutId && order.id !== this.args.resource.id);
+ } catch {
+ this.siblings = [];
+ }
+ }
+}
diff --git a/addon/components/storefront/order/details/tabs/activity.hbs b/addon/components/storefront/order/details/tabs/activity.hbs
new file mode 100644
index 00000000..22295a1a
--- /dev/null
+++ b/addon/components/storefront/order/details/tabs/activity.hbs
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/addon/components/storefront/order/details/tabs/customer.hbs b/addon/components/storefront/order/details/tabs/customer.hbs
new file mode 100644
index 00000000..58f0f523
--- /dev/null
+++ b/addon/components/storefront/order/details/tabs/customer.hbs
@@ -0,0 +1,3 @@
+
+
+
diff --git a/addon/components/storefront/order/details/tabs/data.hbs b/addon/components/storefront/order/details/tabs/data.hbs
new file mode 100644
index 00000000..20b758cd
--- /dev/null
+++ b/addon/components/storefront/order/details/tabs/data.hbs
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/addon/components/storefront/order/details/tabs/items.hbs b/addon/components/storefront/order/details/tabs/items.hbs
new file mode 100644
index 00000000..3c59304d
--- /dev/null
+++ b/addon/components/storefront/order/details/tabs/items.hbs
@@ -0,0 +1,7 @@
+
+
+
+ {{#if @resource.meta.checkout_id}}
+
+ {{/if}}
+
diff --git a/addon/components/storefront/order/details/tabs/payment.hbs b/addon/components/storefront/order/details/tabs/payment.hbs
new file mode 100644
index 00000000..e3a6a302
--- /dev/null
+++ b/addon/components/storefront/order/details/tabs/payment.hbs
@@ -0,0 +1,3 @@
+
+
+
diff --git a/addon/components/storefront/order/details/tabs/route.hbs b/addon/components/storefront/order/details/tabs/route.hbs
new file mode 100644
index 00000000..05ff34fc
--- /dev/null
+++ b/addon/components/storefront/order/details/tabs/route.hbs
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/addon/components/storefront/order/flow-steps.hbs b/addon/components/storefront/order/flow-steps.hbs
new file mode 100644
index 00000000..2ca40724
--- /dev/null
+++ b/addon/components/storefront/order/flow-steps.hbs
@@ -0,0 +1,22 @@
+
+ {{#if this.isCanceled}}
+
+
+ {{t "storefront.order.flow.canceled"}}
+
+ {{/if}}
+
+ {{#each this.steps as |step|}}
+
+
+ {{#if step.isComplete}}
+
+ {{else}}
+ {{step.number}}
+ {{/if}}
+
+ {{step.label}}
+
+ {{/each}}
+
+
diff --git a/addon/components/storefront/order/flow-steps.js b/addon/components/storefront/order/flow-steps.js
new file mode 100644
index 00000000..7ae6f546
--- /dev/null
+++ b/addon/components/storefront/order/flow-steps.js
@@ -0,0 +1,106 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { rankOf } from '../../../utils/order-groups';
+
+const DEFAULT_DELIVERY_FLOW = ['created', 'accepted', 'ready', 'dispatched', 'completed'];
+const DEFAULT_PICKUP_FLOW = ['created', 'accepted', 'pickup_ready', 'picked_up'];
+const ALIASES = {
+ preparing: 'accepted',
+ ready: 'pickup_ready',
+ driver_enroute: 'dispatched',
+ en_route: 'dispatched',
+ out_for_delivery: 'dispatched',
+};
+const CANCELED = ['canceled', 'cancel', 'order_canceled'];
+
+/**
+ * The order's activity flow as numbered steps with the current one marked. A custom
+ * order config contributes its own steps; the default storefront flow is implied.
+ */
+export default class StorefrontOrderFlowStepsComponent extends Component {
+ @service storefrontOrderWorkflow;
+ @service intl;
+
+ get order() {
+ return this.args.order ?? this.args.resource;
+ }
+
+ get status() {
+ return this.storefrontOrderWorkflow.statusFor(this.order);
+ }
+
+ get isCanceled() {
+ return CANCELED.includes(this.status);
+ }
+
+ get isPickup() {
+ return this.storefrontOrderWorkflow.isPickupOrder(this.order);
+ }
+
+ /**
+ * Step codes: walked from `created` through a custom flow's first activities, else the default.
+ */
+ get codes() {
+ const flow = this.order?.order_config?.flow;
+
+ if (flow && !this.storefrontOrderWorkflow.isDefaultStorefrontConfig(this.order)) {
+ const codes = [];
+ let code = flow.created ? 'created' : Object.keys(flow)[0];
+
+ while (code && !codes.includes(code) && codes.length < 10) {
+ codes.push(code);
+ const next = flow[code]?.activities;
+ code = Array.isArray(next) ? next.find((candidate) => !CANCELED.includes(candidate)) : null;
+ }
+
+ return codes;
+ }
+
+ return this.isPickup ? DEFAULT_PICKUP_FLOW : DEFAULT_DELIVERY_FLOW;
+ }
+
+ get currentIndex() {
+ const status = ALIASES[this.status] ?? this.status;
+ const index = this.codes.indexOf(status);
+
+ if (index >= 0) {
+ return index;
+ }
+
+ // An unknown status still lands on the nearest step by rank.
+ const rank = rankOf(this.status);
+ let nearest = 0;
+ this.codes.forEach((code, position) => {
+ if (rankOf(code) <= rank) {
+ nearest = position;
+ }
+ });
+
+ return nearest;
+ }
+
+ get steps() {
+ const flow = this.order?.order_config?.flow ?? {};
+ const current = this.currentIndex;
+
+ return this.codes.map((code, index) => ({
+ code,
+ number: index + 1,
+ label: this.labelFor(code, flow[code]?.status),
+ isComplete: index < current || (index === current && rankOf(this.status) >= 4),
+ isCurrent: index === current && rankOf(this.status) < 4,
+ isUpcoming: index > current,
+ }));
+ }
+
+ /** The short translated step name wins; a custom flow's own status name is the fallback. */
+ labelFor(code, fallback = null) {
+ const key = `storefront.order.flow.${code}`;
+
+ if (!this.intl.exists(key) && fallback) {
+ return fallback;
+ }
+
+ return this.intl.exists(key) ? this.intl.t(key) : String(code).replace(/_/g, ' ');
+ }
+}
diff --git a/addon/components/storefront/order/identity-strip.hbs b/addon/components/storefront/order/identity-strip.hbs
new file mode 100644
index 00000000..a6154e7c
--- /dev/null
+++ b/addon/components/storefront/order/identity-strip.hbs
@@ -0,0 +1,37 @@
+
+
+
{{t "storefront.common.customer"}}
+
+
+
+
{{t "storefront.common.driver"}}
+ {{#if this.hasDriver}}
+
+ {{else if this.isPickup}}
+
{{t "storefront.networks.orders.no-driver-needed"}}
+ {{else if this.canAssignDriver}}
+
+ {{else}}
+
{{t "storefront.networks.orders.unassigned"}}
+ {{/if}}
+
+
+
{{t "storefront.networks.orders.columns.fulfillment"}}
+
+
+ {{if this.isPickup (t "storefront.common.pickup") (t "storefront.networks.orders.delivery")}}
+ · {{if this.scheduledAt this.scheduledAt (if this.bookingAt (format-date-fns this.bookingAt "d MMM HH:mm") (t "storefront.order.asap"))}}
+
+
+
+
{{t "storefront.networks.orders.columns.total"}}
+
{{format-currency this.total this.currency}}
+ {{#if (or this.tip this.promotionCode)}}
+
+ {{#if this.tip}}{{t "storefront.order.incl-tip" tip=(format-currency this.tip this.currency)}}{{/if}}
+ {{#if (and this.tip this.promotionCode)}} · {{/if}}
+ {{#if this.promotionCode}}{{this.promotionCode}}{{#if this.discount}} −{{format-currency this.discount this.currency}}{{/if}}{{/if}}
+
+ {{/if}}
+
+
diff --git a/addon/components/storefront/order/identity-strip.js b/addon/components/storefront/order/identity-strip.js
new file mode 100644
index 00000000..c9866d68
--- /dev/null
+++ b/addon/components/storefront/order/identity-strip.js
@@ -0,0 +1,78 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action, get } from '@ember/object';
+import { isArray } from '@ember/array';
+
+function number(value) {
+ const parsed = parseFloat(value);
+
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+/**
+ * The dispatcher's first glance: who it is for, who delivers it, how it is fulfilled, what it costs, when.
+ */
+export default class StorefrontOrderIdentityStripComponent extends Component {
+ @service storefrontOrderActions;
+ @service storefrontOrderWorkflow;
+
+ get order() {
+ return this.args.order ?? this.args.resource;
+ }
+
+ get isPickup() {
+ return this.storefrontOrderWorkflow.isPickupOrder(this.order);
+ }
+
+ get isTerminal() {
+ return this.storefrontOrderWorkflow.isTerminal(this.order);
+ }
+
+ get hasDriver() {
+ return this.storefrontOrderWorkflow.hasAssignedDriver(this.order);
+ }
+
+ get canAssignDriver() {
+ return !this.isPickup && !this.hasDriver && !this.isTerminal;
+ }
+
+ get currency() {
+ return get(this.order, 'meta.currency') ?? this.order?.currency ?? 'USD';
+ }
+
+ get total() {
+ return number(get(this.order, 'meta.total') ?? this.order?.transaction_amount);
+ }
+
+ get tip() {
+ return number(get(this.order, 'meta.tip')) + number(get(this.order, 'meta.delivery_tip'));
+ }
+
+ get discount() {
+ return number(get(this.order, 'meta.discount'));
+ }
+
+ get promotionCode() {
+ const promotions = get(this.order, 'meta.promotions');
+
+ if (isArray(promotions) && promotions.length) {
+ const first = promotions[0];
+
+ return typeof first === 'string' ? first : (first?.code ?? first?.name ?? null);
+ }
+
+ return null;
+ }
+
+ get scheduledAt() {
+ return this.order?.scheduledAt ?? null;
+ }
+
+ get bookingAt() {
+ return get(this.order, 'meta.booking_at') ?? null;
+ }
+
+ @action assignDriver() {
+ return this.storefrontOrderActions.assignDriver(this.order, this.args.onChange);
+ }
+}
diff --git a/addon/components/storefront/order/panel-header.hbs b/addon/components/storefront/order/panel-header.hbs
index aefe9ee6..34ba69ee 100644
--- a/addon/components/storefront/order/panel-header.hbs
+++ b/addon/components/storefront/order/panel-header.hbs
@@ -1,41 +1,41 @@
-
-
-
-
-
-
-
- {{if @resource.meta.is_pickup "Pickup order" "Delivery order"}}
-
-
- {{n-a @resource.public_id "Order"}}
+
+
+
-
-
-
- {{#if @resource.meta.is_pickup}}
-
-
- {{t "storefront.component.widget.orders.pickup-order"}}
-
- {{else}}
-
-
- Delivery Order
-
- {{/if}}
+
+
-
+
+
diff --git a/addon/components/storefront/product/card.hbs b/addon/components/storefront/product/card.hbs
index 1c7a313b..6ae23d53 100644
--- a/addon/components/storefront/product/card.hbs
+++ b/addon/components/storefront/product/card.hbs
@@ -1,5 +1,10 @@
{{#if (eq @viewMode "list")}}
-
+
+ {{#if @onToggleSelect}}
+
+
+
+ {{/if}}
@@ -37,13 +42,19 @@
{{else}}
-
+
+ {{#if (and this.isDraft this.missing.length)}}
+
+ {{t "storefront.products.card.missing" items=(join ", " this.missing)}}
+
+ {{else if this.isOutOfStock}}
+
+ {{t "storefront.products.card.restock-hint"}}
+
+ {{/if}}
+