diff --git a/addon/adapters/promotion-redemption.js b/addon/adapters/promotion-redemption.js new file mode 100644 index 00000000..b7ad838d --- /dev/null +++ b/addon/adapters/promotion-redemption.js @@ -0,0 +1 @@ +export { default } from './storefront'; diff --git a/addon/components/customer-panel.hbs b/addon/components/customer-panel.hbs index a3f78f15..38e9bd38 100644 --- a/addon/components/customer-panel.hbs +++ b/addon/components/customer-panel.hbs @@ -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}} > - {{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}} diff --git a/addon/components/customer-panel.js b/addon/components/customer-panel.js index 47ffe29c..0db36e69 100644 --- a/addon/components/customer-panel.js +++ b/addon/components/customer-panel.js @@ -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'; @@ -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. @@ -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); @@ -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)) { @@ -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. * diff --git a/addon/components/customer-panel/activity.hbs b/addon/components/customer-panel/activity.hbs new file mode 100644 index 00000000..dc4feff5 --- /dev/null +++ b/addon/components/customer-panel/activity.hbs @@ -0,0 +1,23 @@ +
+
+
+
{{t "storefront.customers.panel.first-order"}}
+
{{#if @insights.first_order_at}}{{format-date-fns @insights.first_order_at "d MMM yyyy"}}{{else}}{{t "storefront.customers.panel.never"}}{{/if}}
+
+
+
{{t "storefront.customers.panel.last-order"}}
+
{{#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}}
+
+
+
{{t "storefront.customers.panel.canceled-orders"}}
+
{{n-a @insights.canceled 0}}
+
+
+
{{t "storefront.common.created"}}
+
{{n-a (format-date-fns @customer.created_at "d MMM yyyy HH:mm")}}
+
+
+ + + +
diff --git a/addon/components/customer-panel/orders.hbs b/addon/components/customer-panel/orders.hbs index d91de32c..b9dd1498 100644 --- a/addon/components/customer-panel/orders.hbs +++ b/addon/components/customer-panel/orders.hbs @@ -29,10 +29,8 @@ - -
- -
+ + {{/each}} diff --git a/addon/components/customer-panel/overview.hbs b/addon/components/customer-panel/overview.hbs new file mode 100644 index 00000000..86471c57 --- /dev/null +++ b/addon/components/customer-panel/overview.hbs @@ -0,0 +1,83 @@ +
+
+
+
{{t "storefront.common.email"}}
+
{{#if this.customer.email}}{{this.customer.email}}{{else}}{{n-a null}}{{/if}}
+
+
+
{{t "storefront.common.phone"}}
+
{{#if this.customer.phone}}{{this.customer.phone}}{{else}}{{n-a null}}{{/if}}
+
+
+
{{t "storefront.customers.panel.preferred-fulfillment"}}
+
{{#if this.insights.preferred_fulfillment}}{{t (concat "storefront.customers.panel.prefers-" this.insights.preferred_fulfillment)}}{{else}}{{n-a null}}{{/if}}
+
+
+
{{t "storefront.customers.panel.account"}}
+
{{#if this.accountLabel}}{{smart-humanize this.accountLabel}}{{else}}{{t "storefront.customers.panel.guest"}}{{/if}}
+
+
+
{{t "storefront.common.internal-id"}}
+
{{n-a this.customer.internal_id}}
+
+
+
{{t "storefront.common.title"}}
+
{{n-a this.customer.title}}
+
+
+ + + {{#if this.placeRows.length}} +
+ {{#each this.placeRows as |row|}} +
+ +
+
{{n-a row.place.address}}
+
+ {{#if row.isDefault}}{{/if}} +
+ {{/each}} +
+ {{else}} +
{{t "storefront.customers.panel.no-places"}}
+ {{/if}} +
+ + {{#if this.showStores}} + + {{#if this.stores.length}} +
+ {{#each this.stores as |store|}} +
+ +
{{n-a store.name store.public_id}}
+ {{t "storefront.networks.orders.items-count" count=store.orders}} +
+ {{/each}} +
+ {{else}} +
{{t "storefront.customers.panel.no-orders"}}
+ {{/if}} +
+ {{/if}} + + + {{#if this.orders.length}} +
+ {{#each this.orders as |order|}} +
+
+ {{order.public_id}} +
{{order.createdAgo}} · {{if order.meta.is_pickup (t "storefront.common.pickup") (t "storefront.networks.orders.delivery")}}{{#if order.meta.storefront}} · {{order.meta.storefront}}{{/if}}
+
+
{{format-currency (or order.meta.total order.transaction_amount 0) order.meta.currency}}
+ +
+ {{/each}} +
+ {{else unless this.loadOrders.isRunning}} +
{{t "storefront.customers.panel.no-orders"}}
+ {{/if}} +
+
diff --git a/addon/components/customer-panel/overview.js b/addon/components/customer-panel/overview.js new file mode 100644 index 00000000..b6b2f0d5 --- /dev/null +++ b/addon/components/customer-panel/overview.js @@ -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?.(); + } +} diff --git a/addon/components/customer-panel/panel-header.hbs b/addon/components/customer-panel/panel-header.hbs index 65036b94..3153f0d0 100644 --- a/addon/components/customer-panel/panel-header.hbs +++ b/addon/components/customer-panel/panel-header.hbs @@ -1,17 +1,19 @@ -
-
-
-
- {{@resource.name}} - - - -
-
-

{{@resource.name}}

-
- - +
+
+
+ {{@resource.name}} +
+
+ {{n-a @resource.name}} + {{#if @resource.type}}{{/if}} + {{#if @insights.preferred_fulfillment}} + + {{/if}} +
+
+ {{@resource.public_id}} + {{#if @insights.since}}{{t "storefront.customers.panel.since" date=(format-date-fns @insights.since "MMM yyyy")}}{{/if}} + {{#if @resource.email}}{{@resource.email}}{{/if}}
@@ -30,4 +32,22 @@
+
+
+
{{t "storefront.common.orders"}}
+
{{if @isLoadingInsights "…" (n-a @insights.orders 0)}}
+
+
+
{{t "storefront.customers.panel.lifetime"}}
+
{{if @isLoadingInsights "…" (format-currency (or @insights.spend 0) (or @insights.currency "USD"))}}
+
+
+
{{t "storefront.customers.panel.average"}}
+
{{if @isLoadingInsights "…" (format-currency (or @insights.average 0) (or @insights.currency "USD"))}}
+
+
+
{{t "storefront.customers.panel.last-order"}}
+
{{#if @insights.last_order_at}}{{format-date-fns @insights.last_order_at "d MMM yyyy"}}{{else}}{{t "storefront.customers.panel.never"}}{{/if}}
+
+
diff --git a/addon/components/customer-panel/places.hbs b/addon/components/customer-panel/places.hbs new file mode 100644 index 00000000..95dead58 --- /dev/null +++ b/addon/components/customer-panel/places.hbs @@ -0,0 +1,23 @@ +
+

{{t "storefront.customers.panel.places-lede"}}

+ {{#if this.rows.length}} +
+ {{#each this.rows as |row|}} +
+ +
+
{{n-a row.place.address}}
+
+ {{#if row.isDefault}} + + {{else}} +
+ {{/each}} +
+ {{else}} +
{{t "storefront.customers.panel.no-places"}}
+ {{/if}} +

{{t "storefront.customers.panel.places-note"}}

+
diff --git a/addon/components/customer-panel/places.js b/addon/components/customer-panel/places.js new file mode 100644 index 00000000..2fa8eeac --- /dev/null +++ b/addon/components/customer-panel/places.js @@ -0,0 +1,44 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * The customer's saved places: set the default, open one in Fleet-Ops. Places are + * Fleet-Ops places, so the pill opens the Fleet-Ops place panel on demand. + */ +export default class CustomerPanelPlacesComponent extends Component { + @service notifications; + @service intl; + + get customer() { + return this.args.customer; + } + + 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 rows() { + return this.places.map((place) => ({ place, isDefault: place.id === this.defaultPlaceId })); + } + + @task *setDefault(place) { + try { + this.customer.setProperties({ place, place_uuid: place.id }); + yield this.customer.save(); + this.notifications.success(this.intl.t('storefront.customers.panel.default-saved', { name: place.name ?? place.address })); + this.args.onChange?.(); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action noop() {} +} diff --git a/addon/components/modals/assign-catalog-subjects.hbs b/addon/components/modals/assign-catalog-subjects.hbs new file mode 100644 index 00000000..8a27b975 --- /dev/null +++ b/addon/components/modals/assign-catalog-subjects.hbs @@ -0,0 +1,14 @@ + + + diff --git a/addon/components/modals/create-catalog.hbs b/addon/components/modals/create-catalog.hbs index 98960bdb..5620722a 100644 --- a/addon/components/modals/create-catalog.hbs +++ b/addon/components/modals/create-catalog.hbs @@ -1,96 +1,10 @@ - - \ No newline at end of file + diff --git a/addon/components/modals/create-food-truck.hbs b/addon/components/modals/create-food-truck.hbs index 3332b6b6..cfc3e543 100644 --- a/addon/components/modals/create-food-truck.hbs +++ b/addon/components/modals/create-food-truck.hbs @@ -1,11 +1,11 @@ - - - -
-
- -
- - - - - - - - - - - {{#if this.isLoading}} - - - - {{else if this.loadFailed}} - - - - {{else}} - {{#each this.codes as |code|}} - - - - - - - {{else}} - - - - {{/each}} - {{/if}} - -
{{t "storefront.promotions.codes.code"}}{{t "storefront.promotions.codes.used"}}{{t "storefront.promotions.common.status"}}{{t "storefront.promotions.list.actions"}}
-

{{t "storefront.promotions.codes.load-failed"}}

-
{{code.code}}{{code.times_used}}{{#if code.usage_limit}} / {{code.usage_limit}}{{/if}} -
{{t "storefront.promotions.codes.empty"}}
-
-
+ diff --git a/addon/components/select-option/store.hbs b/addon/components/select-option/store.hbs new file mode 100644 index 00000000..20fe3866 --- /dev/null +++ b/addon/components/select-option/store.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/store-selector.hbs b/addon/components/store-selector.hbs index 4b349eac..85d5f9f2 100644 --- a/addon/components/store-selector.hbs +++ b/addon/components/store-selector.hbs @@ -1,17 +1,23 @@ -{{#if @activeStore}} -
-
{{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 ``; + } + 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 @onEdit}} + {{n-a this.catalog.name}} + {{else}} + {{n-a this.catalog.name}} + {{/if}} + +
+
{{n-a this.catalog.description (if this.isDraft (t "storefront.catalogs.card.draft-hint") (t "storefront.catalogs.card.no-description"))}}
+ + + +
+ + {{#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}} +
+ + + + +
+ {{t "storefront.catalogs.card.products-count" count=this.productsCount}} +
+ {{#if @onEdit}} + {{#if this.isDraft}} +
+
+
+ {{t "storefront.common.last-modified"}}: {{this.catalog.updatedAgo}} +
+ + + +
+
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 @@ + 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 @@ + + + + + +
+ {{#if @onOpen}} + {{n-a this.network.name}} + {{else}} + {{n-a this.network.name}} + {{/if}} + +
+
{{n-a this.network.currency}} · {{t "storefront.networks.card.stores-count" count=this.storesCount}}
+ + + +
+ + + + + + + +
+ +
+ {{#if @onOpen}} +
+
+ + + +
+
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|}} + + {{/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}} + + + + +