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/add-product-as-entity-button.hbs b/addon/components/add-product-as-entity-button.hbs index 4275e587..dfe87255 100644 --- a/addon/components/add-product-as-entity-button.hbs +++ b/addon/components/add-product-as-entity-button.hbs @@ -1,3 +1,3 @@ {{#if (eq @order.type "storefront")}} - + {{else}} +
+ +
{{n-a store.name store.public_id}}
+ {{t "storefront.customers.panel.orders-count" count=store.orders}} +
+ {{/if}} + {{/each}} + + {{else}} +
{{t "storefront.customers.panel.no-orders"}}
+ {{/if}} + + {{/if}} + + + {{#if this.orders.length}} +
+ {{#each this.orders as |order|}} + + {{/each}} +
+ {{else if (not 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..245e7ea1 --- /dev/null +++ b/addon/components/customer-panel/overview.js @@ -0,0 +1,119 @@ +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; + @service hostRouter; + @service contextPanel; + @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 })); + } + + /** Where the customer ordered; in a network, each member store opens its details (an order placed on the network itself does not). */ + get stores() { + const canOpen = this.storefront.isNetworkContext; + + return (this.insights?.stores ?? []).map((store) => ({ ...store, canOpen: canOpen && String(store.public_id ?? '').startsWith('store_') })); + } + + /** The segments whose rules this customer matches, from the insights endpoint. */ + get segments() { + return this.insights?.segments ?? []; + } + + @action openSegment(segment) { + return this.hostRouter.transitionTo('console.storefront.promotions.segments', { queryParams: { segment: segment.public_id ?? segment.id } }); + } + + @action manageSegments() { + return this.hostRouter.transitionTo('console.storefront.promotions.segments'); + } + + /** In a network, a store the customer ordered from opens that store's details. */ + @action openStore(store) { + const network = this.storefront.activeNetwork; + + if (!network || !store?.public_id) { + return; + } + + this.contextPanel.clear(); + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', network.public_id, store.public_id); + } + + 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.contextScope(); + + 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..0b81a83f 100644 --- a/addon/components/customer-panel/panel-header.hbs +++ b/addon/components/customer-panel/panel-header.hbs @@ -1,21 +1,12 @@ -
-
-
-
- {{@resource.name}} - - - -
-
-

{{@resource.name}}

-
- - -
+
+
+
+ {{@resource.name}} +
+
{{n-a @resource.name}}
-
+
+
+ {{@resource.public_id}} + {{#if @insights.since}}{{t "storefront.customers.panel.since" date=(format-date-fns @insights.since "MMM yyyy")}}{{/if}} + {{#if @resource.email}}{{/if}} +
+
+
+
{{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..6ae0e8ad --- /dev/null +++ b/addon/components/customer-panel/places.hbs @@ -0,0 +1,30 @@ +
+ {{#if this.rows.length}} +
+ {{t "storefront.customers.card.places-count" count=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"}}
+
{{t "storefront.customers.panel.no-places-help"}}
+
+ {{/if}} +
diff --git a/addon/components/customer-panel/places.js b/addon/components/customer-panel/places.js new file mode 100644 index 00000000..cb43df1c --- /dev/null +++ b/addon/components/customer-panel/places.js @@ -0,0 +1,48 @@ +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: add one, 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; + @service storefront; + + 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); + } + } + + /** Opens the address form; the saved place joins the list and the panel refreshes. */ + @action addAddress() { + return this.storefront.addCustomerAddress(this.customer, { onSaved: () => this.args.onChange?.() }); + } +} diff --git a/addon/components/food-truck/pill.hbs b/addon/components/food-truck/pill.hbs new file mode 100644 index 00000000..5d1237c8 --- /dev/null +++ b/addon/components/food-truck/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/food-truck/summary.hbs b/addon/components/food-truck/summary.hbs new file mode 100644 index 00000000..107e8e16 --- /dev/null +++ b/addon/components/food-truck/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/modals/add-products-to-catalog.hbs b/addon/components/modals/add-products-to-catalog.hbs new file mode 100644 index 00000000..622fc128 --- /dev/null +++ b/addon/components/modals/add-products-to-catalog.hbs @@ -0,0 +1,18 @@ + + + {{/each}} + {{#if @fields.custom.length}} +
{{t "storefront.products.form.translations.custom-fields"}}
+ {{#each @fields.custom as |field|}} +
+ {{field.key}} + + {{#unless @disabled}} +
+ {{/each}} + {{/if}} + {{#unless @disabled}} +
+ +
+ {{/unless}} +

{{t "storefront.products.form.translations.help"}}

+
+ {{else}} +

{{t "storefront.products.form.translations.none"}}

+ {{/if}} +
diff --git a/addon/components/storefront/product/form/variants.hbs b/addon/components/storefront/product/form/variants.hbs index 16052b50..b66fbc34 100644 --- a/addon/components/storefront/product/form/variants.hbs +++ b/addon/components/storefront/product/form/variants.hbs @@ -1,78 +1,58 @@ - - - <:actions> -
-
+{{! Each variant is a group: its name and rules, then its options as a small table, Add option under it. }} +
+ {{#each @product.variants as |variant|}} +
+
+
+ {{n-a variant.name}} + + {{if variant.is_required (t "storefront.products.form.variants.required") (t "storefront.products.form.variants.optional")}} + · + {{if variant.is_multiselect (t "storefront.products.form.variants.multiple") (t "storefront.products.form.variants.pick-one")}} + · + {{t "storefront.products.form.variants.options-count" count=variant.options.length}} + +
+
+
+
+ {{#if variant.options.length}} + + + + + + + + + + {{#each variant.options as |variantOption index|}} -
- - - -
- {{else}} -
No options in this variant.
+ + + + + + {{/each}} - - - {{/let}} - - - + +
{{t "storefront.products.form.variants.option"}}{{t "storefront.common.description"}}{{t "storefront.products.form.variants.extra-cost"}}{{t "common.remove"}}
+ {{else}} +
{{t "storefront.products.form.fields.no-options"}}
+ {{/if}} + {{#if (can "storefront create product-variant-option")}} + + {{/if}} +
+ {{else}} +
+ +
{{t "storefront.products.form.variants.none"}}
+
+ {{/each}} +
diff --git a/addon/components/storefront/product/form/videos.hbs b/addon/components/storefront/product/form/videos.hbs index a94ae70f..24b7f615 100644 --- a/addon/components/storefront/product/form/videos.hbs +++ b/addon/components/storefront/product/form/videos.hbs @@ -1,8 +1,40 @@ - -

Add YouTube video urls to associate with this product or service.

-
- - - +{{! YouTube links shown in the product gallery: one row per link, its thumbnail once the link is valid. }} +
+
+ {{t "storefront.products.form.fields.video-urls"}} + {{#unless @disabled}} + + {{/unless}}
- + {{#each @rows as |row|}} +
+ + {{#if row.thumbnail}} + + {{else}} + + {{/if}} + + + {{#unless @disabled}} +
+ {{#if (and row.url (not row.isValid))}} +

{{t "storefront.products.form.videos.invalid"}}

+ {{/if}} + {{else}} +

{{t "storefront.products.form.fields.video-urls-help"}}

+ {{/each}} +
diff --git a/addon/components/storefront/products-loading.hbs b/addon/components/storefront/products-loading.hbs new file mode 100644 index 00000000..d7c130dc --- /dev/null +++ b/addon/components/storefront/products-loading.hbs @@ -0,0 +1,62 @@ +{{#if @hideHeader}} + {{! Under the products toolbar: the list in the view the user chose. }} + + {{#if this.isList}} + + {{else}} + + {{/if}} + +{{else}} + {{! The whole products page: header, the category rail, the tabs and sort, then the list. }} + +
+
+ + + + + +
+ {{#each (array 0 1 2 3 4)}} +
+ + + + + + +
+ {{/each}} + + {{#each (array 0 1 2 3 4)}} +
+ + + +
+ {{/each}} +
+
+
+ + +
+
+ {{#if this.isList}} + + {{else}} + + {{/if}} +
+
+
+{{/if}} diff --git a/addon/components/storefront/products-loading.js b/addon/components/storefront/products-loading.js new file mode 100644 index 00000000..23f8f0d3 --- /dev/null +++ b/addon/components/storefront/products-loading.js @@ -0,0 +1,12 @@ +import Component from '@glimmer/component'; +import { getOwner } from '@ember/application'; + +/** + * The products page while its list loads, drawn in whichever view the user chose: a cards + * grid for the grid view, table rows for the list view. + */ +export default class StorefrontProductsLoadingComponent extends Component { + get isList() { + return getOwner(this).lookup('controller:products/index')?.viewMode === 'list'; + } +} diff --git a/addon/components/storefront/promotion/builder.hbs b/addon/components/storefront/promotion/builder.hbs new file mode 100644 index 00000000..866545dd --- /dev/null +++ b/addon/components/storefront/promotion/builder.hbs @@ -0,0 +1,186 @@ + + + <:actions> + {{t "storefront.promotions.builder.step-of" step=(add this.stepIndex 1) total=this.steps.length}} + + + +
    + {{#each this.steps as |step|}} +
  1. + + + {{#if step.isActive}} +
    + {{#if (eq step.id "what")}} +
    + {{#each this.types as |type|}} + + {{/each}} +
    +
    + {{#if (eq this.promotion.type "percentage")}} + + + + {{else if (eq this.promotion.type "fixed_amount")}} + + + + {{else if (eq this.promotion.type "bogo")}} +
    + + + + + + + + + +

    {{t "storefront.promotions.form.bogo-help"}}

    +
    + {{/if}} + + +