diff --git a/admin-ui/src/app/common/generated-types.ts b/admin-ui/src/app/common/generated-types.ts index cb1d272801..9947620efd 100644 --- a/admin-ui/src/app/common/generated-types.ts +++ b/admin-ui/src/app/common/generated-types.ts @@ -2666,6 +2666,7 @@ export type Query = { shippingMethod?: Maybe, shippingEligibilityCheckers: Array, shippingCalculators: Array, + testShippingMethod: TestShippingMethodResult, taxCategories: Array, taxCategory?: Maybe, taxRates: TaxRateList, @@ -2834,6 +2835,11 @@ export type QueryShippingMethodArgs = { }; +export type QueryTestShippingMethodArgs = { + input: TestShippingMethodInput +}; + + export type QueryTaxCategoryArgs = { id: Scalars['ID'] }; @@ -3050,6 +3056,12 @@ export type ShippingMethodSortParameter = { description?: Maybe, }; +export type ShippingPrice = { + __typename?: 'ShippingPrice', + price: Scalars['Int'], + priceWithTax: Scalars['Int'], +}; + /** The price value where the result has a single price */ export type SinglePrice = { __typename?: 'SinglePrice', @@ -3173,6 +3185,24 @@ export type TaxRateSortParameter = { value?: Maybe, }; +export type TestShippingMethodInput = { + checker: ConfigurableOperationInput, + calculator: ConfigurableOperationInput, + shippingAddress: CreateAddressInput, + lines: Array, +}; + +export type TestShippingMethodOrderLineInput = { + productVariantId: Scalars['ID'], + quantity: Scalars['Int'], +}; + +export type TestShippingMethodResult = { + __typename?: 'TestShippingMethodResult', + eligible: Scalars['Boolean'], + price?: Maybe, +}; + export type UiState = { __typename?: 'UiState', language: LanguageCode, @@ -4178,6 +4208,21 @@ export type ReindexMutationVariables = {}; export type ReindexMutation = ({ __typename?: 'Mutation' } & { reindex: ({ __typename?: 'JobInfo' } & JobInfoFragment) }); +export type SearchForTestOrderQueryVariables = { + term: Scalars['String'], + take: Scalars['Int'] +}; + + +export type SearchForTestOrderQuery = ({ __typename?: 'Query' } & { search: ({ __typename?: 'SearchResponse' } & { items: Array<({ __typename?: 'SearchResult' } & Pick & { price: ({ __typename?: 'SinglePrice' } & Pick), priceWithTax: ({ __typename?: 'SinglePrice' } & Pick) })> }) }); + +export type TestShippingMethodQueryVariables = { + input: TestShippingMethodInput +}; + + +export type TestShippingMethodQuery = ({ __typename?: 'Query' } & { testShippingMethod: ({ __typename?: 'TestShippingMethodResult' } & Pick & { price: Maybe<({ __typename?: 'ShippingPrice' } & Pick)> }) }); + export type ShippingMethodFragment = ({ __typename?: 'ShippingMethod' } & Pick & { checker: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment), calculator: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment) }); export type GetShippingMethodListQueryVariables = { @@ -4212,6 +4257,10 @@ export type UpdateShippingMethodMutationVariables = { export type UpdateShippingMethodMutation = ({ __typename?: 'Mutation' } & { updateShippingMethod: ({ __typename?: 'ShippingMethod' } & ShippingMethodFragment) }); +type DiscriminateUnion = T extends U ? T : never; + +type RequireField = T & { [P in TNames]: (T & { [name: string]: never })[P] }; + export namespace Administrator { export type Fragment = AdministratorFragment; export type User = AdministratorFragment['user']; @@ -5109,6 +5158,24 @@ export namespace Reindex { export type Reindex = JobInfoFragment; } +export namespace SearchForTestOrder { + export type Variables = SearchForTestOrderQueryVariables; + export type Query = SearchForTestOrderQuery; + export type Search = SearchForTestOrderQuery['search']; + export type Items = (NonNullable); + export type Price = (NonNullable)['price']; + export type SinglePriceInlineFragment = (DiscriminateUnion)['price'], '__typename'>, { __typename: 'SinglePrice' }>); + export type PriceWithTax = (NonNullable)['priceWithTax']; + export type _SinglePriceInlineFragment = (DiscriminateUnion)['priceWithTax'], '__typename'>, { __typename: 'SinglePrice' }>); +} + +export namespace TestShippingMethod { + export type Variables = TestShippingMethodQueryVariables; + export type Query = TestShippingMethodQuery; + export type TestShippingMethod = TestShippingMethodQuery['testShippingMethod']; + export type Price = (NonNullable); +} + export namespace ShippingMethod { export type Fragment = ShippingMethodFragment; export type Checker = ConfigurableOperationFragment; diff --git a/admin-ui/src/app/core/providers/local-storage/local-storage.service.ts b/admin-ui/src/app/core/providers/local-storage/local-storage.service.ts index 0c8fa9127d..38dcd05a21 100644 --- a/admin-ui/src/app/core/providers/local-storage/local-storage.service.ts +++ b/admin-ui/src/app/core/providers/local-storage/local-storage.service.ts @@ -1,6 +1,8 @@ +import { Location } from '@angular/common'; import { Injectable } from '@angular/core'; -export type LocalStorageKey = 'refreshToken' | 'authToken' | 'activeChannelToken'; +export type LocalStorageKey = 'activeChannelToken'; +export type LocalStorageLocationBasedKey = 'shippingTestOrder' | 'shippingTestAddress'; const PREFIX = 'vnd_'; /** @@ -8,6 +10,7 @@ const PREFIX = 'vnd_'; */ @Injectable() export class LocalStorageService { + constructor(private location: Location) {} /** * Set a key-value pair in the browser's LocalStorage */ @@ -16,6 +19,14 @@ export class LocalStorageService { localStorage.setItem(keyName, JSON.stringify(value)); } + /** + * Set a key-value pair specific to the current location (url) + */ + public setForCurrentLocation(key: LocalStorageLocationBasedKey, value: any) { + const compositeKey = this.getLocationBasedKey(key); + this.set(compositeKey as any, value); + } + /** * Set a key-value pair in the browser's SessionStorage */ @@ -40,12 +51,25 @@ export class LocalStorageService { return result; } + /** + * Get the value of the given key for the current location (url) + */ + public getForCurrentLocation(key: LocalStorageLocationBasedKey): any { + const compositeKey = this.getLocationBasedKey(key); + return this.get(compositeKey as any); + } + public remove(key: LocalStorageKey): void { const keyName = this.keyName(key); sessionStorage.removeItem(keyName); localStorage.removeItem(keyName); } + private getLocationBasedKey(key: string) { + const path = this.location.path(); + return key + path; + } + private keyName(key: LocalStorageKey): string { return PREFIX + key; } diff --git a/admin-ui/src/app/data/definitions/settings-definitions.ts b/admin-ui/src/app/data/definitions/settings-definitions.ts index 7b04b651c6..9d2f69d664 100644 --- a/admin-ui/src/app/data/definitions/settings-definitions.ts +++ b/admin-ui/src/app/data/definitions/settings-definitions.ts @@ -566,3 +566,38 @@ export const REINDEX = gql` } ${JOB_INFO_FRAGMENT} `; + +export const SEARCH_FOR_TEST_ORDER = gql` + query SearchForTestOrder($term: String!, $take: Int!) { + search(input: { groupByProduct: false, term: $term, take: $take }) { + items { + productVariantId + productVariantName + productPreview + price { + ... on SinglePrice { + value + } + } + priceWithTax { + ... on SinglePrice { + value + } + } + sku + } + } + } +`; + +export const TEST_SHIPPING_METHOD = gql` + query TestShippingMethod($input: TestShippingMethodInput!) { + testShippingMethod(input: $input) { + eligible + price { + price + priceWithTax + } + } + } +`; diff --git a/admin-ui/src/app/data/providers/settings-data.service.ts b/admin-ui/src/app/data/providers/settings-data.service.ts index b51a77688a..6ed873cdde 100644 --- a/admin-ui/src/app/data/providers/settings-data.service.ts +++ b/admin-ui/src/app/data/providers/settings-data.service.ts @@ -33,6 +33,9 @@ import { GetZones, JobState, RemoveMembersFromZone, + SearchForTestOrder, + TestShippingMethod, + TestShippingMethodInput, UpdateChannel, UpdateChannelInput, UpdateCountry, @@ -73,6 +76,8 @@ import { GET_TAX_RATE_LIST, GET_ZONES, REMOVE_MEMBERS_FROM_ZONE, + SEARCH_FOR_TEST_ORDER, + TEST_SHIPPING_METHOD, UPDATE_CHANNEL, UPDATE_COUNTRY, UPDATE_GLOBAL_SETTINGS, @@ -309,4 +314,23 @@ export class SettingsDataService { input: { state: JobState.RUNNING }, }); } + + searchForTestOrder(term: string, take: number) { + return this.baseDataService.query( + SEARCH_FOR_TEST_ORDER, + { + take, + term, + }, + ); + } + + testShippingMethod(input: TestShippingMethodInput) { + return this.baseDataService.query( + TEST_SHIPPING_METHOD, + { + input, + }, + ); + } } diff --git a/admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.html b/admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.html index 30984f0bcd..46cf9be6d0 100644 --- a/admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.html +++ b/admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.html @@ -4,7 +4,7 @@ + + diff --git a/admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.scss b/admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.scss new file mode 100644 index 0000000000..9e4c10d0e0 --- /dev/null +++ b/admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.scss @@ -0,0 +1,28 @@ +@import "variables"; +.test-result { + &.success .card-block { + background-color: $color-success-100; + } + &.error .card-block { + background-color: $color-error-100; + } + &.unknown .card-block { + background-color: $color-grey-100; + } +} +.result-details { + transition: opacity 0.2s; + &.stale { + opacity: 0.5; + } +} + +.eligible-icon { + display: inline-block; + .success { + color: $color-success-500; + } + .error { + color: $color-error-500; + } +} diff --git a/admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.ts b/admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.ts new file mode 100644 index 0000000000..f6a2bd0d9c --- /dev/null +++ b/admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +import { CurrencyCode, TestShippingMethodResult } from '../../../common/generated-types'; + +@Component({ + selector: 'vdr-shipping-method-test-result', + templateUrl: './shipping-method-test-result.component.html', + styleUrls: ['./shipping-method-test-result.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ShippingMethodTestResultComponent { + @Input() testResult: TestShippingMethodResult; + @Input() okToRun = false; + @Input() testDataUpdated = false; + @Input() currencyCode: CurrencyCode; + @Output() runTest = new EventEmitter(); +} diff --git a/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.html b/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.html new file mode 100644 index 0000000000..cd391b271c --- /dev/null +++ b/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.html @@ -0,0 +1,29 @@ +
+
+ {{ 'settings.test-address' | translate }} +
+
+
+ + + + + + + + + + + + + + + + +
+
+
diff --git a/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.scss b/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.scss new file mode 100644 index 0000000000..994798bfde --- /dev/null +++ b/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.scss @@ -0,0 +1,3 @@ +clr-input-container { + margin-bottom: 12px; +} diff --git a/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.ts b/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.ts new file mode 100644 index 0000000000..164d15cb96 --- /dev/null +++ b/admin-ui/src/app/settings/components/test-address-form/test-address-form.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { Observable, Subscription } from 'rxjs'; + +import { GetAvailableCountries } from '../../../common/generated-types'; +import { LocalStorageService } from '../../../core/providers/local-storage/local-storage.service'; +import { DataService } from '../../../data/providers/data.service'; + +export interface TestAddress { + city: string; + province: string; + postalCode: string; + countryCode: string; +} + +@Component({ + selector: 'vdr-test-address-form', + templateUrl: './test-address-form.component.html', + styleUrls: ['./test-address-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TestAddressFormComponent implements OnInit, OnDestroy { + @Output() addressChange = new EventEmitter(); + availableCountries$: Observable; + form: FormGroup; + private subscription: Subscription; + + constructor( + private formBuilder: FormBuilder, + private dataService: DataService, + private localStorageService: LocalStorageService, + ) {} + + ngOnInit() { + this.availableCountries$ = this.dataService.settings + .getAvailableCountries() + .mapSingle(result => result.countries.items); + const storedValue = this.localStorageService.getForCurrentLocation('shippingTestAddress'); + const initialValue: TestAddress = storedValue + ? storedValue + : { + city: '', + countryCode: '', + postalCode: '', + province: '', + }; + this.addressChange.emit(initialValue); + + this.form = this.formBuilder.group(initialValue); + this.subscription = this.form.valueChanges.subscribe(value => { + this.localStorageService.setForCurrentLocation('shippingTestAddress', value); + this.addressChange.emit(value); + }); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } +} diff --git a/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.html b/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.html new file mode 100644 index 0000000000..54a7d4b2a4 --- /dev/null +++ b/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.html @@ -0,0 +1,71 @@ +
+
+ {{ 'settings.test-order' | translate }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'order.product-name' | translate }}{{ 'order.product-sku' | translate }}{{ 'order.unit-price' | translate }}{{ 'order.quantity' | translate }}{{ 'order.total' | translate }}
+ + {{ line.name }}{{ line.sku }} + {{ line.unitPriceWithTax / 100 | currency: currencyCode }} + + + + + {{ (line.unitPriceWithTax * line.quantity) / 100 | currency: currencyCode }} +
{{ 'order.sub-total' | translate }}{{ subTotal / 100 | currency: currencyCode }}
+ + +
+
{{ 'settings.add-products-to-test-order' | translate }}
+ +
+
+
+ +
+
diff --git a/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.scss b/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.scss new file mode 100644 index 0000000000..ecd69c33e5 --- /dev/null +++ b/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.scss @@ -0,0 +1,8 @@ +@import "variables"; +.empty-placeholder { + color: $color-grey-400; + text-align: center; +} +.empty-text { + font-size: 22px; +} diff --git a/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.ts b/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.ts new file mode 100644 index 0000000000..07c756da17 --- /dev/null +++ b/admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.ts @@ -0,0 +1,117 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core'; +import { NgSelectComponent } from '@ng-select/ng-select'; +import { concat, merge, Observable, of, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, mapTo, switchMap, tap } from 'rxjs/operators'; + +import { CurrencyCode, SearchForTestOrder } from '../../../common/generated-types'; +import { LocalStorageService } from '../../../core/providers/local-storage/local-storage.service'; +import { DataService } from '../../../data/providers/data.service'; + +export interface TestOrderLine { + id: string; + name: string; + preview: string; + sku: string; + unitPriceWithTax: number; + quantity: number; +} + +@Component({ + selector: 'vdr-test-order-builder', + templateUrl: './test-order-builder.component.html', + styleUrls: ['./test-order-builder.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TestOrderBuilderComponent implements OnInit { + @Output() orderLinesChange = new EventEmitter(); + + lines: TestOrderLine[] = []; + currencyCode: CurrencyCode; + searchInput$ = new Subject(); + resultSelected$ = new Subject(); + searchLoading = false; + searchResults$: Observable; + @ViewChild('autoComplete', { static: true }) + private ngSelect: NgSelectComponent; + + get subTotal(): number { + return this.lines.reduce((sum, l) => sum + l.unitPriceWithTax * l.quantity, 0); + } + constructor(private dataService: DataService, private localStorageService: LocalStorageService) {} + + ngOnInit() { + this.lines = this.loadFromLocalStorage(); + if (this.lines) { + this.orderLinesChange.emit(this.lines); + } + this.initSearchResults(); + this.dataService.settings.getActiveChannel('cache-first').single$.subscribe(result => { + this.currencyCode = result.activeChannel.currencyCode; + }); + } + + selectResult(result: SearchForTestOrder.Items) { + if (result) { + this.resultSelected$.next(); + this.ngSelect.clearModel(); + this.addToLines(result); + } + } + + removeLine(line: TestOrderLine) { + this.lines = this.lines.filter(l => l.id !== line.id); + this.persistToLocalStorage(); + this.orderLinesChange.emit(this.lines); + } + + private addToLines(result: SearchForTestOrder.Items) { + if (!this.lines.find(l => l.id === result.productVariantId)) { + this.lines.push({ + id: result.productVariantId, + name: result.productVariantName, + preview: result.productPreview, + quantity: 1, + sku: result.sku, + unitPriceWithTax: result.priceWithTax.value, + }); + this.persistToLocalStorage(); + this.orderLinesChange.emit(this.lines); + } + } + + private initSearchResults() { + const searchItems$ = this.searchInput$.pipe( + debounceTime(200), + distinctUntilChanged(), + tap(() => (this.searchLoading = true)), + switchMap(term => { + if (!term) { + return of([]); + } + return this.dataService.settings + .searchForTestOrder(term, 10) + .mapSingle(result => result.search.items); + }), + tap(() => (this.searchLoading = false)), + ); + + const clear$ = this.resultSelected$.pipe(mapTo([])); + this.searchResults$ = concat(of([]), merge(searchItems$, clear$)); + } + + private persistToLocalStorage() { + this.localStorageService.setForCurrentLocation('shippingTestOrder', this.lines); + } + + private loadFromLocalStorage(): TestOrderLine[] { + return this.localStorageService.getForCurrentLocation('shippingTestOrder') || []; + } +} diff --git a/admin-ui/src/app/settings/settings.module.ts b/admin-ui/src/app/settings/settings.module.ts index bff8fcd050..240712d946 100644 --- a/admin-ui/src/app/settings/settings.module.ts +++ b/admin-ui/src/app/settings/settings.module.ts @@ -17,10 +17,13 @@ import { RoleDetailComponent } from './components/role-detail/role-detail.compon import { RoleListComponent } from './components/role-list/role-list.component'; import { ShippingMethodDetailComponent } from './components/shipping-method-detail/shipping-method-detail.component'; import { ShippingMethodListComponent } from './components/shipping-method-list/shipping-method-list.component'; +import { ShippingMethodTestResultComponent } from './components/shipping-method-test-result/shipping-method-test-result.component'; import { TaxCategoryDetailComponent } from './components/tax-category-detail/tax-category-detail.component'; import { TaxCategoryListComponent } from './components/tax-category-list/tax-category-list.component'; import { TaxRateDetailComponent } from './components/tax-rate-detail/tax-rate-detail.component'; import { TaxRateListComponent } from './components/tax-rate-list/tax-rate-list.component'; +import { TestAddressFormComponent } from './components/test-address-form/test-address-form.component'; +import { TestOrderBuilderComponent } from './components/test-order-builder/test-order-builder.component'; import { ZoneSelectorDialogComponent } from './components/zone-selector-dialog/zone-selector-dialog.component'; import { AdministratorResolver } from './providers/routing/administrator-resolver'; import { ChannelResolver } from './providers/routing/channel-resolver'; @@ -55,6 +58,9 @@ import { settingsRoutes } from './settings.routes'; PaymentMethodListComponent, PaymentMethodDetailComponent, GlobalSettingsComponent, + TestOrderBuilderComponent, + TestAddressFormComponent, + ShippingMethodTestResultComponent, ], entryComponents: [ZoneSelectorDialogComponent], providers: [ diff --git a/admin-ui/src/i18n-messages/en.json b/admin-ui/src/i18n-messages/en.json index cace2920cf..b094864e20 100644 --- a/admin-ui/src/i18n-messages/en.json +++ b/admin-ui/src/i18n-messages/en.json @@ -131,6 +131,8 @@ "notify-update-success": "Updated { entity }", "open": "Open", "password": "Password", + "price": "Price", + "price-with-tax": "Price with tax", "remember-me": "Remember me", "remove": "Remove", "results-count": "{ count } {count, plural, one {result} other {results}}", @@ -477,6 +479,7 @@ "settings": { "add-countries-to-zone": "Add countries to zone...", "add-countries-to-zone-success": "Added { countryCount } {countryCount, plural, one {country} other {countries}} to zone \"{ zoneName }\"", + "add-products-to-test-order": "Add products to the test order", "administrator": "Administrator", "catalog": "Catalog", "channel-token": "Channel token", @@ -493,6 +496,7 @@ "default-shipping-zone": "Default shipping zone", "default-tax-zone": "Default tax zone", "delete": "Delete", + "elibigle": "Eligible", "email-address": "Email address", "first-name": "First name", "last-name": "Last name", @@ -506,6 +510,7 @@ "remove-countries-from-zone": "Remove countries from zone...", "remove-countries-from-zone-success": "Removed { countryCount } {countryCount, plural, one {country} other {countries}} from zone \"{ zoneName }\"", "roles": "Roles", + "search-by-product-name-or-sku": "Search by product name or SKU", "section": "Section", "select-zone": "Select zone", "settings": "Settings", @@ -513,6 +518,10 @@ "shipping-eligibility-checker": "Shipping eligibility checker", "tax-category": "Tax category", "tax-rate": "Tax rate", + "test-address": "Test address", + "test-order": "Test order", + "test-result": "Test result", + "test-shipping-method": "Test shipping method", "track-inventory-default": "Track inventory by default", "update": "Update", "zone": "Zone"