diff --git a/composer.json b/composer.json index b48dbf29..d3d44c2d 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "swag/swag-extension-store", - "version": "4.2.3", + "version": "7.0.0", "description": "SWAG Extension Store", "type": "shopware-platform-plugin", "license": "MIT", diff --git a/src/Controller/DataController.php b/src/Controller/DataController.php deleted file mode 100644 index 7089ef64..00000000 --- a/src/Controller/DataController.php +++ /dev/null @@ -1,74 +0,0 @@ - ['api'], '_acl' => ['system.plugin_maintain']])] -class DataController -{ - private StoreDataProvider $dataProvider; - - public function __construct(StoreDataProvider $dataProvider) - { - $this->dataProvider = $dataProvider; - } - - #[Route('/api/_action/extension-store/list', name: 'api.extension.list', methods: ['POST', 'GET'])] - public function getExtensionList(Request $request, Context $context): Response - { - if ($request->getMethod() === Request::METHOD_POST) { - $criteria = ExtensionCriteria::fromArray($request->request->all()); - } else { - $criteria = ExtensionCriteria::fromArray($request->query->all()); - } - - $listing = $this->dataProvider->getListing($criteria, $context); - - return new JsonResponse([ - 'data' => $listing, - 'meta' => [ - 'total' => $listing->getTotal(), - ], - ]); - } - - #[Route('/api/_action/extension-store/detail/{id}', name: 'api.extension.detail', methods: ['GET'])] - public function detail(int $id, Context $context): Response - { - return new JsonResponse($this->dataProvider->getExtensionDetails($id, $context)); - } - - #[Route('/api/_action/extension-store/{id}/reviews', name: 'api.extension.reviews', methods: ['GET'])] - public function reviews(int $id, Request $request, Context $context): Response - { - $criteria = ExtensionCriteria::fromArray($request->query->all()); - - return new JsonResponse($this->dataProvider->getReviews($id, $criteria, $context)); - } - - #[Route('/api/_action/extension-store/store-filters', name: 'api.extension.store_filters', methods: ['GET'])] - public function listingFilters(Request $request, Context $context): JsonResponse - { - /** @var RequestQueryParameters $params */ - $params = $request->query->all(); - - return new JsonResponse($this->dataProvider->getListingFilters($params, $context)); - } -} diff --git a/src/Controller/LicenseController.php b/src/Controller/LicenseController.php index f75c9b99..529874e8 100644 --- a/src/Controller/LicenseController.php +++ b/src/Controller/LicenseController.php @@ -35,15 +35,15 @@ public function createCart(Request $request, Context $context): JsonResponse $extensionId = $request->request->get('extensionId'); $variantId = $request->request->get('variantId'); - if (!is_numeric($extensionId)) { + if (!\is_int($extensionId) && !\is_string($extensionId)) { throw ExtensionStoreException::invalidExtensionId(); } - if (!is_numeric($variantId)) { + if (!\is_int($variantId) && !\is_string($variantId)) { throw ExtensionStoreException::invalidVariantId(); } - $cart = $this->licenseService->createCart((int) $extensionId, (int) $variantId, $context); + $cart = $this->licenseService->createCart($extensionId, $variantId, $context); return new JsonResponse($cart); } diff --git a/src/Exception/ExtensionStoreApiException.php b/src/Exception/ExtensionStoreApiException.php index 987dceee..5f5f7be8 100644 --- a/src/Exception/ExtensionStoreApiException.php +++ b/src/Exception/ExtensionStoreApiException.php @@ -11,15 +11,15 @@ class ExtensionStoreApiException extends StoreApiException { protected string $apiCode; + protected string $description; + public function __construct(ClientException $exception) { parent::__construct($exception); - try { - $data = json_decode($exception->getResponse()->getBody()->getContents(), true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - } + $data = $this->getData($exception); $this->apiCode = $data['code'] ?? ''; + $this->description = $data['description'] ?? ''; } public function getErrors(bool $withTrace = false): \Generator @@ -29,7 +29,31 @@ public function getErrors(bool $withTrace = false): \Generator yield [ ...$error, 'apiCode' => $this->apiCode, + 'description' => $this->description, ]; } } + + /** + * @return array + */ + private function getData(ClientException $exception): array + { + $body = $exception->getResponse()->getBody(); + + // The parent constructor already read the body to its end and left the stream at EOF, + // so reading it again without rewinding yields an empty string. + if ($body->isSeekable()) { + $body->rewind(); + } + + try { + $data = json_decode($body->getContents(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + // A body may be valid JSON without being an object, e.g. a bare string or number. + return \is_array($data) ? $data : []; + } } diff --git a/src/Migration/Migration1779864357StoreIFrameUrlConfig.php b/src/Migration/Migration1779864357StoreIFrameUrlConfig.php new file mode 100644 index 00000000..b09ac2cf --- /dev/null +++ b/src/Migration/Migration1779864357StoreIFrameUrlConfig.php @@ -0,0 +1,41 @@ +fetchOne( + 'SELECT 1 FROM system_config WHERE configuration_key = :configurationKey', + ['configurationKey' => $configurationKey], + ); + + if ($configurationExists !== false) { + return; + } + + $connection->insert('system_config', [ + 'id' => Uuid::randomBytes(), + 'configuration_key' => $configurationKey, + 'configuration_value' => '{"_value": "https://sky-bridge-store.production.shopware.in"}', + 'created_at' => (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ]); + } +} diff --git a/src/Resources/app/administration/eslint.config.mjs b/src/Resources/app/administration/eslint.config.mjs index 9b7a3be1..b4a06654 100644 --- a/src/Resources/app/administration/eslint.config.mjs +++ b/src/Resources/app/administration/eslint.config.mjs @@ -91,6 +91,7 @@ export default tseslint.config( 'stylistic/quote-props': ['error', 'as-needed'], 'stylistic/no-extra-semi': ['error'], 'stylistic/comma-dangle': ['error', 'always-multiline'], + 'stylistic/quotes': ['error', 'single'], /* stylistic rules */ /* typescript rules */ diff --git a/src/Resources/app/administration/jest.config.js b/src/Resources/app/administration/jest.config.js index a02b87c0..6afd3c42 100644 --- a/src/Resources/app/administration/jest.config.js +++ b/src/Resources/app/administration/jest.config.js @@ -46,6 +46,7 @@ module.exports = { '^@shopware-ag/meteor-component-library$': `${process.env.ADMIN_PATH}/node_modules/@shopware-ag/meteor-component-library/dist/common/index.js`, vue$: `${process.env.ADMIN_PATH}/node_modules/vue/dist/vue.cjs.js`, '^@vue/test-utils$': `${process.env.ADMIN_PATH}/node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js`, + '^lodash-es/(.*)$': 'lodash/$1', }, testEnvironmentOptions: { diff --git a/src/Resources/app/administration/package-lock.json b/src/Resources/app/administration/package-lock.json index c6676fa8..ed8327d8 100644 --- a/src/Resources/app/administration/package-lock.json +++ b/src/Resources/app/administration/package-lock.json @@ -8,6 +8,9 @@ "name": "swag-extension-store", "version": "0.0.1", "license": "MIT", + "dependencies": { + "@shopware-ag/meteor-admin-sdk": "^6.8.0" + }, "devDependencies": { "@babel/preset-env": "^7.20.2", "@shopware-ag/eslint-config-base": "2.0.0", @@ -3776,6 +3779,30 @@ "npm": ">=10.0.0" } }, + "node_modules/@shopware-ag/meteor-admin-sdk": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@shopware-ag/meteor-admin-sdk/-/meteor-admin-sdk-6.8.0.tgz", + "integrity": "sha512-BZDTd1i4VxWTtyn2k8yMspho4T1dI9WGQI7j/msp7+LMo/G+Ly5wjryKxt5MwqtD/50/i5jLaUtimToDLM4lfA==", + "license": "MIT", + "dependencies": { + "jwt-decode": "^4.0.0", + "localforage": "^1.10.0", + "lodash-es": "^4.17.21", + "semver": "^7.7.1" + } + }, + "node_modules/@shopware-ag/meteor-admin-sdk/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -7161,6 +7188,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10061,6 +10094,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10105,6 +10147,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -10112,6 +10163,15 @@ "dev": true, "license": "MIT" }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -10132,6 +10192,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/src/Resources/app/administration/package.json b/src/Resources/app/administration/package.json index 909378d2..c3250163 100644 --- a/src/Resources/app/administration/package.json +++ b/src/Resources/app/administration/package.json @@ -30,5 +30,8 @@ }, "optionalDependencies": { "administration": "^6.x" + }, + "dependencies": { + "@shopware-ag/meteor-admin-sdk": "^6.8.0" } } diff --git a/src/Resources/app/administration/src/global.types.ts b/src/Resources/app/administration/src/global.types.ts index 497b3da3..f9ee8d88 100644 --- a/src/Resources/app/administration/src/global.types.ts +++ b/src/Resources/app/administration/src/global.types.ts @@ -1,12 +1,19 @@ import type { PropType as TPropType } from 'vue'; import type InAppPurchasesService from './module/sw-in-app-purchases/service/in-app-purchases.service'; import type { AxiosError } from 'axios'; +import type ExtensionStorePreferencesService + from 'SwagExtensionStore/module/sw-extension-store/service/extension-store-preferences.service'; +import type { + ExtensionStoreChannelService, +} from 'SwagExtensionStore/module/sw-extension-store/service/extension-store-channel.service'; declare global { type PropType = TPropType; interface ServiceContainer { inAppPurchasesService: InAppPurchasesService; + extensionStoreChannelService: ExtensionStoreChannelService; + extensionStorePreferencesService: ExtensionStorePreferencesService; } type ErrorResponse = AxiosError<{ errors: Array }>; diff --git a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/index.js b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/index.js index 344ed100..c7614655 100644 --- a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/index.js +++ b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/index.js @@ -7,7 +7,7 @@ const BADGE_NEW_REMOVAL_DATE = '2025-01-01 00:00:00.000'; export default Shopware.Component.wrapComponentConfig({ template, - inject: ['extensionStoreDataService', 'acl'], + inject: ['acl'], data() { return { @@ -29,10 +29,6 @@ export default Shopware.Component.wrapComponentConfig({ showBadge() { return (new Date()) < (new Date(BADGE_NEW_REMOVAL_DATE)); }, - - linkToStatisticsAppExists() { - return !!this.routeToApp; - }, }, created() { @@ -43,28 +39,14 @@ export default Shopware.Component.wrapComponentConfig({ async createdComponent() { this.isAppInstalled = !!Shopware.Context.app.config.bundles[STATISTICS_APP_NAME]; - if (!this.canAccessExtensionStore()) { - // Take the user to the extension store so that they see an "Access denied" message - this.routeToApp = { name: 'sw.extension.store' }; - - return; - } - - this.extensionStoreDataService.getExtensionByName( - STATISTICS_APP_NAME, - Shopware.Context.api, - ).then((extension) => { - if (extension) { - this.routeToApp = { name: 'sw.extension.store.detail', params: { id: extension.id } }; - } - }); + // Even if the user does not have permissions, take them to the extension store so that they see an + // "Access denied" message. + this.routeToApp = this.canAccessExtensionStore() + ? { name: 'sw.extension.store.detail', params: { id: STATISTICS_APP_NAME } } + : { name: 'sw.extension.store' }; }, goToStatisticsAppDetailPage() { - if (!this.linkToStatisticsAppExists) { - return; - } - this.$router.push(this.routeToApp); }, diff --git a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.html.twig b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.html.twig index 57c2a40d..d9cbbce1 100644 --- a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.html.twig +++ b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.html.twig @@ -45,7 +45,6 @@ {{ $t('sw-dashboard-statistics-promotion-card.go-to-app') }} diff --git a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.spec.js b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.spec.js index bb32b0eb..ccee477d 100644 --- a/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.spec.js +++ b/src/Resources/app/administration/src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card/sw-dashboard-statistics-promotion-card.spec.js @@ -8,26 +8,14 @@ Shopware.Component.register( ); describe('src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-card', () => { - const extensionStoreDataService = {}; const router = {}; - async function createWrapper(isAppExistingInTheStore = true, hasPermission = true) { - const app = !isAppExistingInTheStore ? null : { - id: 99999, - label: 'Statistics service app by shopware', - name: STATISTICS_APP_NAME, - }; - - extensionStoreDataService.getExtensionByName = hasPermission - ? jest.fn(() => Promise.resolve(app)) - : jest.fn(() => Promise.reject(new Error('Request failed with status code 403'))); - + async function createWrapper(hasPermission = true) { router.push = jest.fn(); return mount(await Shopware.Component.build('sw-dashboard-statistics-promotion-card'), { global: { provide: { - extensionStoreDataService, acl: { can: () => hasPermission, }, @@ -86,27 +74,12 @@ describe('src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-ca expect(wrapper.find('.sw-dashboard-statistics-promotion-banner').exists()).toBe(false); }); - it('disables the button if the app is not found in the store', async () => { - const wrapper = await createWrapper(false); - - expect(wrapper.find('button[disabled]').exists()).toBe(true); - - wrapper.vm.goToStatisticsAppDetailPage(); - expect(router.push).toHaveBeenCalledTimes(0); - }); - - it('does not fetch the extension when the user does not have permission to access the extension store', async () => { - await createWrapper(true, false); - expect(extensionStoreDataService.getExtensionByName).toHaveBeenCalledTimes(0); - }); - it( 'redirects to the extension store even if the user does not have permission to access the extension store', async () => { - const wrapper = await createWrapper(true, false); + const wrapper = await createWrapper(false); expect(wrapper.find('button').exists()).toBe(true); - expect(wrapper.find('button[disabled]').exists()).toBe(false); wrapper.vm.goToStatisticsAppDetailPage(); expect(router.push).toHaveBeenNthCalledWith(1, { name: 'sw.extension.store' }); @@ -129,16 +102,15 @@ describe('src/module/sw-dashboard/component/sw-dashboard-statistics-promotion-ca jest.useRealTimers(); }); - it('enables the button if the app is found in the store', async () => { - const wrapper = await createWrapper(true); + it('navigates to the extension detail page on button click', async () => { + const wrapper = await createWrapper(); expect(wrapper.find('button').exists()).toBe(true); - expect(wrapper.find('button[disabled]').exists()).toBe(false); wrapper.vm.goToStatisticsAppDetailPage(); expect(router.push).toHaveBeenNthCalledWith(1, { name: 'sw.extension.store.detail', - params: { id: 99999 }, + params: { id: STATISTICS_APP_NAME }, }); }); }); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/index.js deleted file mode 100644 index ffeaf0ce..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/index.js +++ /dev/null @@ -1,526 +0,0 @@ -import template from './sw-extension-buy-modal.html.twig'; -import './sw-extension-buy-modal.scss'; - -const { Utils } = Shopware; - -/** - * @private - */ -export default { - template, - - inject: [ - 'shopwareExtensionService', - 'extensionStoreService', - 'extensionStoreLicensesService', - ], - - emits: [ - 'modal-close', - ], - - mixins: [ - 'sw-extension-error', - ], - - props: { - extension: { - type: Object, - required: true, - }, - }, - - data() { - return { - tocAccepted: false, - selectedVariantId: null, - isLoading: false, - permissionsAccepted: false, - legalTextAccepted: false, - showPermissionsModal: false, - showLegalTextModal: false, - privacyExtensionsAccepted: false, - showPrivacyModal: false, - checkoutStep: null, - checkoutError: null, - cart: null, - paymentMeans: [], - }; - }, - - computed: { - actualPrice() { - const cartPosition = this.cart && this.cart.positions && this.cart.positions[0]; - const netPrice = cartPosition && cartPosition.netPrice; - - if (netPrice && cartPosition && cartPosition.firstMonthFree) { - return Utils.format.currency(0, 'EUR'); - } - - if (netPrice) { - return Utils.format.currency(netPrice, 'EUR', 2); - } - - return Utils.format.currency( - this.extensionStoreService.getPriceFromVariant(this.selectedVariant), - 'EUR', - ); - }, - - actualPriceSnippet() { - if (this.extensionStoreService.isVariantOfTypeFree(this.selectedVariant)) { - return 'sw-extension-store.general.labelFree'; - } - - if (this.extensionStoreService.isVariantOfTypeBuy(this.selectedVariant)) { - return 'sw-extension-store.general.labelPriceOneTime'; - } - - const cartPosition = this.cart && this.cart.positions && this.cart.positions[0]; - const netPrice = cartPosition && cartPosition.netPrice; - - if (netPrice && cartPosition && cartPosition.firstMonthFree) { - return 'sw-extension-store.general.labelPriceFirstMonth'; - } - - if (this.isRentDurationYearly(this.selectedVariant)) { - return 'sw-extension-store.general.labelPricePerYear'; - } - - return 'sw-extension-store.general.labelPricePerMonth'; - }, - - finalPriceSnippet() { - if (this.isRentDurationYearly(this.selectedVariant)) { - return 'sw-extension-store.buy-modal.rent.yearly.finalPrice'; - } - - return 'sw-extension-store.buy-modal.rent.monthly.finalPrice'; - }, - - recommendedVariants() { - return this.extensionStoreService.orderVariantsByRentDuration(this.extension.variants); - }, - - selectedVariant() { - return this.extension.variants.find((variant) => { - return variant.id === this.selectedVariantId; - }); - }, - - dateFilter() { - return Utils.format.date; - }, - - purchaseButtonLabel() { - if (this.isVariantOfTypeFree(this.selectedVariant)) { - return this.$t('sw-extension-store.component.sw-extension-buy-modal.purchaseButtonsLabels.free'); - } - - if (this.isVariantOfTypeRent(this.selectedVariant)) { - return this.$t('sw-extension-store.component.sw-extension-buy-modal.purchaseButtonsLabels.rent'); - } - - return this.$t('sw-extension-store.component.sw-extension-buy-modal.purchaseButtonsLabels.buy'); - }, - - vatIncludedClasses() { - return { - 'is--hidden': this.isVariantOfTypeFree(this.selectedVariant), - }; - }, - - renewalDate() { - const date = new Date(); - - if (this.isRentDurationMonthly(this.selectedVariant)) { - date.setMonth(date.getMonth() + 1); - } else { - date.setMonth(date.getMonth() + 12); - } - - return date; - }, - - renewalDateClasses() { - return { - 'is--hidden': false === this.isVariantOfTypeRent(this.selectedVariant), - }; - }, - - extensionHasPermissions() { - return !!Object.keys(this.extension.permissions).length; - }, - - canPurchaseExtension() { - return this.tocAccepted && - this.permissionsAccepted && - this.legalTextAccepted && - this.privacyExtensionsAccepted && - this.userCanBuyFromStore && - !this.showPaymentWarning; - }, - - /* onPrem we need to check if the user is connected to the store in saas we check if the user has a plan */ - userCanBuyFromStore() { - // Trigger for recompute value - - const _trigger = this.tocAccepted; - - return Shopware.Store.get('shopwareExtensions').userInfo !== null; - }, - - showPaymentWarning() { - return (this.paymentMeans || []).length <= 0 && - this.cart && this.cart.payment && this.cart.payment.paymentMeanRequired; - }, - - checkoutSteps() { - return Object.freeze({ - CHECKOUT: null, - SUCCESS: 'checkout-success', - FAILED: 'checkout-failed', - }); - }, - - showPaymentSelection() { - return (this.paymentMeans || []).length > 0 && - this.cart && this.cart.payment && this.cart.payment.paymentMeanRequired; - }, - - paymentText() { - if (!this.cart || !this.cart.payment || !this.cart.payment.paymentText) { - return null; - } - - return this.$sanitize(this.cart.payment.paymentText, { - ALLOWED_TAGS: ['a', 'b', 'i', 'u', 'br', 'strong', 'p', 'br'], - ALLOWED_ATTR: ['href', 'target', 'rel'], - }); - }, - - legalText() { - if (!this.cart || !this.cart.legalText) { - return null; - } - - return this.$sanitize(this.cart.legalText, { - ALLOWED_TAGS: ['a', 'b', 'i', 'u', 'br', 'strong', 'p', 'br'], - ALLOWED_ATTR: ['href', 'target', 'rel'], - }); - }, - - selectedPaymentMean: { - get() { - return this.cart && - this.cart.payment && - this.cart.payment.paymentMean && - this.cart.payment.paymentMean.id; - }, - set(paymentId) { - if (this.cart && this.cart.payment && this.cart.payment.paymentMean) { - this.cart.payment.paymentMean.id = paymentId; - } - - this.cart.payment.paymentMean = { - id: paymentId, - }; - }, - }, - - checkoutErrorDocumentationLink() { - return Utils.get(this.checkoutError, 'meta.documentationLink', null); - }, - - firstMonthFree() { - return this.cart && this.cart.positions[0] && this.cart.positions[0].firstMonthFree === true; - }, - }, - - async created() { - const variantId = this.recommendedVariants.length > 0 ? this.recommendedVariants[0].id : null; - - this.setSelectedVariantId(variantId); - this.permissionsAccepted = !this.extensionHasPermissions; - this.privacyExtensionsAccepted = !this.extension.privacyPolicyExtension; - - await this.fetchPlan(); - - this.legalTextAccepted = !this.legalText; - }, - - watch: { - selectedVariantId() { - this.getCart(); - }, - - /** - * userCanBuyFromStore is used in the getCart() method to make an early return - * - * @param value - */ - userCanBuyFromStore(value) { - if (value) { - this.getCart(); - } - }, - - /** - * Watcher to automatically sync the `legalTextAccepted` value with the permissions. - * When an App has permissions which need to be accepted, the permissions automatically accept - * the `legalText` as well because for Apps they are combined in a single checkbox. - * - * @param value - */ - permissionsAccepted(value) { - this.legalTextAccepted = value; - }, - }, - - methods: { - emitClose() { - if (this.isLoading) { - return; - } - - this.$emit('modal-close'); - }, - - getDiscountClasses(variant) { - return { - 'is--discounted': this.hasActiveDiscount(variant), - }; - }, - - getVariantPrice(variant) { - return this.extensionStoreService.getCalculatedPrice(variant); - }, - - getVariantPriceSnippet(variant) { - return this.extensionStoreService.getPriceSnippetForVariant(variant); - }, - - getVariantDiscountInformationSnippet(variant) { - if (this.isRentDurationYearly(variant)) { - return 'sw-extension-store.buy-modal.rent.yearly.discountInformation'; - } - - return 'sw-extension-store.buy-modal.rent.monthly.discountInformation'; - }, - - getVariantCancellationInformationSnippet(variant) { - if (this.isRentDurationYearly(variant)) { - return 'sw-extension-store.buy-modal.rent.yearly.cancellationInformation'; - } - - return 'sw-extension-store.buy-modal.rent.monthly.cancellationInformation'; - }, - - getVariantClasses(variant) { - return { - 'is--monthly': this.isRentDurationMonthly(variant), - 'is--yearly': this.isRentDurationYearly(variant), - }; - }, - - getVariantBadgeClasses(variant) { - return { - 'sw-extension-buy-modal__variants-card-badge-discounted': this.hasActiveDiscount(variant), - 'is--monthly': this.isRentDurationMonthly(variant), - 'is--yearly': this.isRentDurationYearly(variant), - }; - }, - - getVariantBadgeSavings(variant) { - const monthlyVariant = this.extension.variants.find((v) => { - return this.extensionStoreService.isRentDurationMonthly(v); - }); - - if (!monthlyVariant) { - return 0; - } - - const monthlyNetPrice = monthlyVariant.netPrice; - const variantNetPrice = variant.netPrice; - const variantDuration = variant.duration; - - const hasSaving = variantNetPrice !== (monthlyNetPrice * variantDuration); - const hasDiscount = this.hasActiveDiscount(variant); - - let savingsPercentage = 0; - - if (hasSaving && false === hasDiscount) { - savingsPercentage = 100 * (1 - variantNetPrice / (monthlyNetPrice * variantDuration)); - } else if (false === hasSaving && hasDiscount) { - savingsPercentage = 100 * (1 - variant.discountCampaign.discountedPrice / variant.netPrice); - } else if (hasSaving && hasDiscount) { - const savingPercentage = 100 * (1 - variantNetPrice / (monthlyNetPrice * variantDuration)); - const discountPercentage = 100 * (1 - variant.discountCampaign.discountedPrice / (monthlyNetPrice * variantDuration)); - - savingsPercentage = discountPercentage - savingPercentage; - } - - return savingsPercentage.toFixed(2).replace(/\.00$/, ''); - }, - - hasActiveDiscount(variant) { - return this.extensionStoreService.isVariantDiscounted(variant); - }, - - isRentDurationMonthly(variant) { - return this.extensionStoreService.isRentDurationMonthly(variant); - }, - - isRentDurationYearly(variant) { - return this.extensionStoreService.isRentDurationYearly(variant); - }, - - isVariantOfTypeFree(variant) { - return this.extensionStoreService.isVariantOfTypeFree(variant); - }, - - isVariantOfTypeBuy(variant) { - return this.extensionStoreService.isVariantOfTypeBuy(variant); - }, - - isVariantOfTypeRent(variant) { - return this.extensionStoreService.isVariantOfTypeRent(variant); - }, - - setSelectedVariantId(variantId) { - if (this.isLoading) { - return; - } - - this.selectedVariantId = variantId; - }, - - variantCardClass(variant) { - return { - 'is--selected': variant.id === this.selectedVariantId, - }; - }, - - onChangeVariantSelection(variant) { - this.setSelectedVariantId(variant.id); - }, - - async purchaseExtension() { - this.isLoading = true; - - let checkoutResult = null; - - try { - await this.orderCart(); - - await this.shopwareExtensionService.updateExtensionData(); - checkoutResult = this.checkoutSteps.SUCCESS; - } catch (error) { - this.handleErrors(error); - checkoutResult = this.checkoutSteps.FAILED; - - if (Utils.get(error, 'response.data.errors[0]', null)) { - this.checkoutError = error.response.data.errors[0]; - } - } finally { - await this.shopwareExtensionService.updateExtensionData(); - this.checkoutStep = checkoutResult; - - this.isLoading = false; - } - }, - - async orderCart() { - await this.extensionStoreLicensesService.orderCart(this.cart); - }, - - async getCart() { - if (!this.userCanBuyFromStore) { - return; - } - - this.isLoading = true; - - try { - const response = await this.extensionStoreLicensesService.newCart(this.extension.id, this.selectedVariantId); - this.cart = response.data; - } catch (error) { - this.handleErrors(error); - this.isLoading = false; - this.emitClose(); - } finally { - this.isLoading = false; - } - }, - - renderPrice(price) { - return Utils.format.currency(price, 'EUR'); - }, - - renderBuyPrice(variant) { - if (this.hasActiveDiscount(variant)) { - return this.renderPrice(variant.discountCampaign.discountedPrice); - } - - return this.renderPrice(variant.netPrice); - }, - - handleErrors(error) { - this.showExtensionErrors(error); - }, - - openPermissionsModal() { - this.showPermissionsModal = true; - }, - - closePermissionsModal() { - this.showPermissionsModal = false; - }, - - openLegalTextModal() { - this.showLegalTextModal = true; - }, - - closeLegalTextModal() { - this.showLegalTextModal = false; - }, - - async fetchPlan() { - this.isLoading = true; - await this.shopwareExtensionService.checkLogin(); - await this.getPaymentMeans(); - this.isLoading = false; - }, - - async getPaymentMeans() { - this.extensionStoreLicensesService.getPaymentMeans().then((response) => { - this.paymentMeans = response.data; - }).catch(error => { - const errorMessages = (error.response && error.response.data && error.response.data.errors) || []; - - if (!Array.isArray(errorMessages)) { - Shopware.Utils.debug.warn('Payment loading error', error); - return; - } - - errorMessages.forEach(e => { - this.createNotificationError({ - system: true, - autoClose: false, - growl: true, - title: e.title, - message: e.detail, - }); - }); - }); - }, - - openPrivacyModal() { - this.showPrivacyModal = true; - }, - - closePrivacyModal() { - this.showPrivacyModal = false; - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.html.twig deleted file mode 100644 index 000d10d5..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.html.twig +++ /dev/null @@ -1,466 +0,0 @@ -{% block sw_extension_buy_modal %} - - - {% block sw_extension_buy_modal_content %} - - - - - - {% endblock %} - -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.scss deleted file mode 100644 index 27a8f5c8..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.scss +++ /dev/null @@ -1,242 +0,0 @@ -@import "~scss/variables"; -@import "~scss/mixins"; - -$padding-summary-card: 22px; - -.sw-extension-buy-modal { - .is--hidden { - display: none; - } - - &__variants-selection { - .sw-extension-buy-modal__variants-card { - margin-bottom: 16px; - } - } - - &__extension-summary { - display: grid; - justify-items: center; - margin-bottom: 24px; - } - - .sw-extension-icon { - margin-bottom: 16px; - width: 72px; - height: 72px; - } - - &__extension-summary-name { - color: var(--color-text-primary-default); - margin-bottom: 0; - font-weight: $font-weight-semi-bold; - font-size: $font-size-l; - } - - &__extension-summary-trial-phase { - display: flex; - align-items: center; - gap: 4px; - font-size: $font-size-xs; - margin-top: 8px; - - .mt-icon { - color: var(--color-icon-brand-default); - } - } - - &__extension-summary-disclaimer { - color: var(--color-text-secondary-default); - font-size: $font-size-xs; - margin-top: 16px; - } - - &__variants-card { - display: grid; - grid-template-columns: auto 1fr auto; - grid-column-gap: 12px; - grid-row-gap: 5px; - border: 1px solid var(--color-border-primary-default); - border-radius: $border-radius-default; - padding: 16px; - cursor: pointer; - - &.is--selected { - border: 1px solid var(--color-border-brand-default); - background: var(--color-background-brand-default); - } - - label { - cursor: pointer; - } - - & > * { - font-size: $font-size-xs; - grid-column: 2; - } - - .sw-extension-buy-modal__rent { - color: var(--color-text-secondary-default); - line-height: $line-height-md; - - strong { - color: var(--color-text-primary-default); - } - } - } - - &__variants-card-input { - align-self: center; - grid-column: 1; - } - - &__variants-card-badge { - background-color: var(--color-background-tertiary-default); - border-radius: $border-radius-pill; - color: var(--color-text-primary-default); - display: inline-block; - font-size: $font-size-xxs; - font-weight: $font-weight-medium; - grid-column: 3; - grid-row: 1; - height: $line-height-xs; - line-height: $line-height-xs; - margin-right: 2px; - padding: 0 5px; - - &.sw-extension-buy-modal__variants-card-badge-discounted { - background-color: var(--color-background-critical-default); - } - } - - &__variant-description { - font-weight: $font-weight-semi-bold; - font-size: $font-size-s; - } - - &__list-price { - font-weight: $font-weight-bold; - font-size: 16px; - } - - .is--discounted { - color: var(--color-text-critical-default); - - .sw-extension-buy-modal__list-price { - text-decoration: line-through; - margin-right: 4px; - font-weight: $font-weight-regular; - font-size: $font-size-xs; - } - - .sw-extension-buy-modal__discounted-price { - font-weight: $font-weight-bold; - font-size: $font-size-s; - } - } - - &__variant-summary { - margin-bottom: 16px; - border-bottom: 1px solid var(--color-border-primary-default); - color: var(--color-text-secondary-default); - font-size: $font-size-xs; - padding: 16px 0; - text-align: right; - - .sw-extension-buy-modal__variant-summary-price-subline { - font-size: $font-size-xs; - color: var(--color-text-secondary-default); - line-height: var(--font-line-height-xs); - } - } - - &__variant-summary-price { - display: flex; - font-size: $font-size-l; - justify-content: space-between; - align-items: flex-end; - color: var(--color-text-primary-default); - margin-bottom: 4px; - } - - &__variant-summary-actual-price { - font-size: $font-size-l; - font-weight: $font-weight-bold; - } - - &__plus-vat, - &__general-text-block { - font-size: $font-size-xxs; - color: var(--color-text-secondary-default); - line-height: $line-height-xs; - margin-bottom: var(--scale-size-16); - } - - &__content-installed { - text-align: center; - - .sw-circle-icon { - margin: 16px auto; - } - - h3 { - margin-bottom: 0.5em; - } - - p { - color: var(--color-text-secondary-default); - } - - .mt-button { - margin-top: 16px; - } - } - - .mt-field--checkbox { - margin-bottom: 16px; - } - - &__checkbox { - margin-bottom: var(--scale-size-16); - font-size: $font-size-xs; - - .mt-field--checkbox__content { - align-items: inherit; - - .mt-field__label { - display: block; - - .mt-button { - background: none; - border: none; - padding: 0; - margin-bottom: 4px; - color: var(--color-text-brand-default); - font-weight: $font-weight-regular; - text-decoration: underline; - line-height: inherit; - } - } - } - } - - &__variant-text-block { - font-size: $font-size-xs; - line-height: 22px; - margin-top: 5px; - } - - .mt-field__label .is--required::after { - display: none; - } - - &__external-link { - color: var(--color-text-brand-default); - - .sw-icon { - height: 10px; - width: 16px; - margin-bottom: 2px; - } - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.spec.js deleted file mode 100644 index a94954a5..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-buy-modal/sw-extension-buy-modal.spec.js +++ /dev/null @@ -1,797 +0,0 @@ -import { mount } from '@vue/test-utils'; - -/* service */ -import 'src/module/sw-extension/service'; -import ShopwareExtensionService from 'src/module/sw-extension/service/shopware-extension.service'; -import ExtensionStoreActionService from 'src/module/sw-extension/service/extension-store-action.service'; -import ShopwareDiscountCampaignService from 'src/app/service/discount-campaign.service'; -import StoreApiService from 'src/core/service/api/store.api.service'; -import ExtensionStoreService from 'SwagExtensionStore/module/sw-extension-store/service/extension-store.service'; -import ExtensionStoreLicensesService from - 'SwagExtensionStore/module/sw-extension-store/service/extension-store-licenses.service'; - -/* stores */ -import 'src/module/sw-extension/store/extensions.store'; - -/* mixin */ -import 'src/module/sw-extension/mixin/sw-extension-error.mixin'; - -Shopware.Component.register( - 'sw-extension-buy-modal', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-buy-modal'), -); - -describe('src/module/sw-extension/component/sw-extension-buy-modal', () => { - function provideTestExtension(overrides) { - return { - name: 'test-app', - label: 'Test app', - permissions: {}, - variants: [], - ...overrides, - }; - } - - async function createWrapper(overrides, httpClient = null) { - const loginService = { - getToken: () => Promise.resolve({ access: true, refresh: true }), - }; - - httpClient = httpClient ?? { - post: jest.fn(), - get: jest.fn(() => Promise.resolve()), - }; - - const shopwareExtensionService = new ShopwareExtensionService( - { fetchAppModules() { return Promise.resolve(); } }, - new ExtensionStoreActionService(httpClient, loginService), - new ShopwareDiscountCampaignService(), - new StoreApiService(httpClient, loginService), - ); - - const extensionStoreLicensesService = new ExtensionStoreLicensesService(httpClient, loginService); - - const extensionStoreService = new ExtensionStoreService( - new ShopwareDiscountCampaignService(), - shopwareExtensionService, - ); - - const wrapper = mount(await Shopware.Component.build('sw-extension-buy-modal'), { - props: { - extension: provideTestExtension(overrides), - }, - global: { - renderStubDefaultSlot: true, - mixins: [ - Shopware.Mixin.getByName('sw-extension-error'), - ], - provide: { - shopwareExtensionService, - extensionStoreLicensesService, - extensionStoreService, - }, - stubs: { - 'sw-modal': { - template: `
- - -
`, - }, - 'sw-base-field': await wrapTestComponent('sw-base-field', { sync: true }), - 'sw-field-error': await wrapTestComponent('sw-field-error', { sync: true }), - 'sw-gtc-checkbox': await wrapTestComponent('sw-gtc-checkbox', { sync: true }), - 'sw-extension-adding-failed': true, - 'sw-extension-adding-success': true, - 'sw-extension-permissions-modal': true, - 'sw-extension-privacy-policy-extensions-modal': true, - 'sw-external-link': true, - 'sw-extension-icon': true, - 'sw-single-select': true, - 'sw-ai-copilot-badge': true, - 'sw-help-text': true, - 'sw-inheritance-switch': true, - }, - }, - }); - - Shopware.Application.getApplicationRoot = () => { return wrapper.vm; }; - - return wrapper; - } - - it('does not show permissions and privacy checkbox if extension has not entries', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 497, - trialPhaseIncluded: false, - discountCampaign: null, - extensions: [], - }], - }); - - expect(wrapper.find('.sw-extension-buy-modal__checkbox-permissions--test-app').exists()).toBe(false); - expect(wrapper.find('.sw-extension-buy-modal__checkbox-privacy-policy--test-app').exists()).toBe(false); - }); - - it('opens and closes permission modal correctly', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 497, - trialPhaseIncluded: false, - discountCampaign: null, - extensions: [], - }], - permissions: { - product: [{ - entity: 'product', - operation: 'read', - }], - }, - }); - await flushPromises(); - - await wrapper.get('.sw-extension-buy-modal__checkbox-permissions--test-app .permissions-modal-trigger') - .trigger('click'); - - await wrapper.getComponent('sw-extension-permissions-modal-stub').vm.$emit('modal-close'); - - expect(wrapper.find('sw-extension-permissions-modal-stub').exists()).toBe(false); - }); - - it('opens and closes modal for privacy adjustments', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 497, - trialPhaseIncluded: false, - discountCampaign: null, - extensions: [], - }], - privacyPolicyExtension: 'Don\'t talk about the fight club!', - }); - - await wrapper.get('.sw-extension-buy-modal__checkbox-privacy-policy--test-app .privacy-policy-modal-trigger') - .trigger('click'); - - await wrapper.getComponent('sw-extension-privacy-policy-extensions-modal-stub').vm.$emit('modal-close'); - - expect(wrapper.find('sw-extension-privacy-policy-extensions-modal-stub').exists()).toBe(false); - }); - - it('should show app provider legal text checkbox and modal for on-premise plugins without permissions', async () => { - const httpClient = { - get: () => Promise.resolve(), - post: (route) => { - if (route === '/_action/extension-store/cart/new') { - return Promise.resolve({ - data: { - bookingShop: {}, - grossPrice: 0, - licenseShop: {}, - legalText: '

Sub processor text

', - }, - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 497, - trialPhaseIncluded: false, - discountCampaign: null, - extensions: [], - }], - type: 'plugin', - }, httpClient); - - // Check gtc checkbox to re-evaluate computed `userCanBuyFromStore` - await wrapper.get('.sw-gtc-checkbox input').setChecked(true); - await flushPromises(); - - // App provider checkbox should exist - expect(wrapper.find('.sw-extension-buy-modal__checkbox-app-provider').exists()).toBeTruthy(); - - // Open app provider details via link - await wrapper.get('.sw-extension-buy-modal__checkbox-app-provider--test-app .legal-text-modal-trigger') - .trigger('click'); - - // Expect app provider detail modal to be present with correct legal text - expect(wrapper.find('.sw-extension-buy-modal__legal-text-modal').exists()).toBeTruthy(); - expect(wrapper.find('.sw-extension-buy-modal__legal-text-modal').text()).toBe('Sub processor text'); - - // Close the app provider detail modal again - await wrapper.getComponent('.sw-extension-buy-modal__legal-text-modal').vm.$emit('modal-close'); - - // Expect app provider detail modal to be removed - expect(wrapper.find('.sw-extension-buy-modal__legal-text-modal').exists()).toBeFalsy(); - }); - - it('shows failed status if extensions could not be bought', async () => { - const httpClient = { - get: jest.fn((route) => { - if (route === '/_action/extension/installed') { - return Promise.resolve({ - data: [], - }); - } - return Promise.resolve(); - }), - post: (route) => { - if (route === '/_action/extension-store/cart/order') { - - return Promise.reject({ - response: { data: { errors: [] } }, - }); - } - - if (route === '/_action/extension/refresh') { - return Promise.resolve({ - data: [], - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 497, - trialPhaseIncluded: false, - discountCampaign: null, - extensions: [], - }], - }, httpClient); - await flushPromises(); - - // check gtc checkbox - await wrapper.get('.sw-gtc-checkbox input').setChecked(true); - await flushPromises(); - - const buyButton = wrapper.findComponent('button.mt-button--primary'); - - expect(buyButton.attributes('disabled')).toBeUndefined(); - await buyButton.trigger('click'); - - await flushPromises(); - - expect(httpClient.get).toHaveBeenCalledWith('/_action/extension/installed', { - headers: expect.objectContaining({ - Accept: expect.anything(), - Authorization: expect.anything(), - 'Content-Type': 'application/json', - }), - version: expect.anything(), - }); - - await wrapper.getComponent('sw-extension-adding-failed-stub').trigger('close'); - - expect(wrapper.emitted('modal-close')).toBeTruthy(); - }); - - it('shows success status if extensions was installed successfully', async () => { - const httpClient = { - get: jest.fn((route) => { - if (route === '/_action/extension/installed') { - return Promise.resolve({ - data: [], - }); - } - - return Promise.resolve(); - }), - post: (route) => { - if (route === '/_action/extension-store/purchase') { - return Promise.resolve(); - } - - if (route === '/_action/extension/refresh') { - return Promise.resolve({ - data: [], - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 497, - trialPhaseIncluded: false, - discountCampaign: null, - extensions: [], - }], - }, httpClient); - await flushPromises(); - - // check gtc checkbox - await wrapper.get('.sw-gtc-checkbox input').setChecked(true); - - const buyButton = wrapper.findComponent('button.mt-button--primary'); - - expect(buyButton.attributes('disabled')).toBeUndefined(); - await buyButton.trigger('click'); - - await flushPromises(); - - expect(httpClient.get).toHaveBeenCalledWith('/_action/extension/installed', { - headers: expect.objectContaining({ - Accept: expect.anything(), - Authorization: expect.anything(), - 'Content-Type': 'application/json', - }), - version: expect.anything(), - }); - - await wrapper.getComponent('sw-extension-adding-success-stub').trigger('close'); - - expect(wrapper.emitted('modal-close')).toBeTruthy(); - }); - - it('renders free variant', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'free', - netPrice: 0, - discountCampaign: null, - }], - }); - - const badgeEl = wrapper.find('.sw-extension-buy-modal__variants-card-badge'); - - expect(badgeEl.exists()).toBe(false); - }); - - it('renders monthly rent variant', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: null, - }], - }); - - const variantEl = wrapper.find('.sw-extension-buy-modal__rent.is--monthly'); - - expect(variantEl.exists()).toBe(true); - expect(variantEl.text()) - .toContain('sw-extension-store.general.labelPricePerMonth'); - expect(variantEl.text()) - .toContain('sw-extension-store.buy-modal.rent.monthly.cancellationInformation'); - }); - - it('renders monthly rent variant with discount campaign', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: { - discountedPrice: 7, - discountedPricePerMonth: 7, - discountAppliesForMonths: 3, - startDate: '2021-01-27T00:01:00+01:00', - endDate: '2121-01-28T00:01:00+01:00', - }, - }], - }); - - const variantEl = wrapper.find('.sw-extension-buy-modal__rent.is--monthly'); - const badgeEl = wrapper.find('.sw-extension-buy-modal__variants-card-badge.is--monthly'); - - expect(variantEl.exists()).toBe(true); - expect(variantEl.text()) - .toContain('sw-extension-store.general.labelPricePerMonth'); - expect(variantEl.text()) - .toContain('sw-extension-store.buy-modal.rent.monthly.cancellationInformation'); - expect(variantEl.text()) - .toContain('sw-extension-store.buy-modal.rent.monthly.discountInformation'); - expect(badgeEl.text()) - .toBe('sw-extension-store.general.labelSavings'); - }); - - it('renders yearly rent variant', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: null, - }, { - id: 78674, - type: 'rent', - duration: 12, - netPrice: 100, - netPricePerMonth: 8.33, - discountCampaign: null, - }], - }); - - const variantEl = wrapper.find('.sw-extension-buy-modal__rent.is--yearly'); - const badgeEl = wrapper.find('.sw-extension-buy-modal__variants-card-badge.is--yearly'); - - expect(variantEl.exists()).toBe(true); - expect(variantEl.text()) - .toContain('sw-extension-store.general.labelPricePerYear'); - expect(variantEl.text()) - .toContain('sw-extension-store.buy-modal.rent.yearly.cancellationInformation'); - expect(badgeEl.text()) - .toBe('sw-extension-store.general.labelSavings'); - }); - - it('renders multiple variants', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78672, - type: 'free', - netPrice: 0, - discountCampaign: null, - }, { - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: null, - }, { - id: 78674, - type: 'rent', - duration: 12, - netPrice: 100, - netPricePerMonth: 8.33, - discountCampaign: null, - }, { - id: 78675, - type: 'buy', - netPrice: 100, - discountCampaign: null, - }], - }); - - const variantFreeEl = wrapper.find('.sw-extension-buy-modal__free'); - const variantMonthlyEl = wrapper.find('.sw-extension-buy-modal__rent.is--monthly'); - const variantYearlyEl = wrapper.find('.sw-extension-buy-modal__rent.is--yearly'); - const variantBuyEl = wrapper.find('.sw-extension-buy-modal__buy'); - - expect(variantFreeEl.exists()).toBe(true); - expect(variantMonthlyEl.exists()).toBe(true); - expect(variantYearlyEl.exists()).toBe(true); - expect(variantBuyEl.exists()).toBe(true); - }); - - it('renders yearly rent variant with discount campaign', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: null, - }, { - id: 78674, - type: 'rent', - duration: 12, - netPrice: 100, - netPricePerMonth: 8.33, - discountCampaign: { - discountedPrice: 70, - discountedPricePerMonth: 5.83, - discountAppliesForMonths: null, - startDate: '2021-01-27T00:01:00+01:00', - endDate: '2121-01-28T00:01:00+01:00', - }, - }], - }); - - const variantEl = wrapper.find('.sw-extension-buy-modal__rent.is--yearly'); - const badgeEl = wrapper.find('.sw-extension-buy-modal__variants-card-badge.is--yearly'); - - expect(variantEl.exists()).toBe(true); - expect(variantEl.text()) - .toContain('sw-extension-store.general.labelPricePerYear'); - expect(variantEl.text()) - .toContain('sw-extension-store.buy-modal.rent.yearly.cancellationInformation'); - expect(variantEl.text()) - .toContain('sw-extension-store.buy-modal.rent.yearly.discountInformation'); - expect(badgeEl.text()) - .toBe('sw-extension-store.general.labelSavings'); - }); - - it('renders actual price for monthly rent variant', async () => { - const httpClient = { - get: () => Promise.resolve(), - post: (route) => { - if (route === '/_action/extension-store/cart/new') { - return Promise.resolve({ - data: { - bookingShop: {}, - grossPrice: 11.9, - licenseShop: {}, - positions: [{ - netPrice: 10, - firstMonthFree: false, - }], - }, - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: null, - trialPhaseIncluded: true, - }], - }, httpClient); - - await flushPromises(); - - const actualPrice = wrapper.find('.sw-extension-buy-modal__variant-summary-actual-price'); - - expect(actualPrice.text()) - .toContain('sw-extension-store.general.labelPricePerMonth'); - }); - - it('renders actual price for monthly rent variant with trial phase', async () => { - const httpClient = { - get: () => Promise.resolve(), - post: (route) => { - if (route === '/_action/extension-store/cart/new') { - return Promise.resolve({ - data: { - bookingShop: {}, - grossPrice: 11.9, - licenseShop: {}, - positions: [{ - netPrice: 10, - firstMonthFree: true, - }], - }, - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78673, - type: 'rent', - duration: 1, - netPrice: 10, - netPricePerMonth: 10, - discountCampaign: null, - trialPhaseIncluded: true, - }], - }, httpClient); - - await flushPromises(); - - const actualPrice = wrapper.find('.sw-extension-buy-modal__variant-summary-actual-price'); - const finalPrice = wrapper.find('.sw-extension-buy-modal__variant-summary-price-subline.is--final-price'); - - expect(actualPrice.text()) - .toContain('sw-extension-store.general.labelPriceFirstMonth'); - expect(finalPrice.exists()).toBe(true); - expect(finalPrice.text()) - .toContain('sw-extension-store.buy-modal.rent.monthly.finalPrice'); - }); - - it('renders actual price for yearly rent variant', async () => { - const httpClient = { - get: () => Promise.resolve(), - post: (route) => { - if (route === '/_action/extension-store/cart/new') { - return Promise.resolve({ - data: { - bookingShop: {}, - grossPrice: 119.00, - licenseShop: {}, - positions: [{ - netPrice: 100, - firstMonthFree: false, - }], - }, - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'rent', - duration: 12, - netPrice: 100, - netPricePerMonth: 8.33, - discountCampaign: null, - trialPhaseIncluded: true, - }], - }, httpClient); - - await flushPromises(); - - const actualPrice = wrapper.find('.sw-extension-buy-modal__variant-summary-actual-price'); - - expect(actualPrice.text()) - .toContain('sw-extension-store.general.labelPricePerYear'); - }); - - it('renders actual price for yearly rent variant with trial phase', async () => { - const httpClient = { - get: () => Promise.resolve(), - post: (route) => { - if (route === '/_action/extension-store/cart/new') { - return Promise.resolve({ - data: { - bookingShop: {}, - grossPrice: 119.00, - licenseShop: {}, - positions: [{ - netPrice: 100, - firstMonthFree: true, - }], - }, - }); - } - - if (route === '/_action/store/checklogin') { - return Promise.resolve({ - data: { - userInfo: { email: 'j.doe@shopware.com' }, - }, - }); - } - - return Promise.resolve(); - }, - }; - - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'rent', - duration: 12, - netPrice: 100, - netPricePerMonth: 8.33, - discountCampaign: null, - trialPhaseIncluded: true, - }], - }, httpClient); - - await flushPromises(); - - const actualPrice = wrapper.find('.sw-extension-buy-modal__variant-summary-actual-price'); - const finalPrice = wrapper.find('.sw-extension-buy-modal__variant-summary-price-subline.is--final-price'); - - expect(actualPrice.text()) - .toContain('sw-extension-store.general.labelPriceFirstMonth'); - expect(finalPrice.exists()).toBe(true); - expect(finalPrice.text()) - .toContain('sw-extension-store.buy-modal.rent.yearly.finalPrice'); - }); - - it('renders purchase button label for free variant', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'free', - netPrice: 0, - discountCampaign: null, - }], - }); - - expect(wrapper.find('.sw-extension-buy-modal__purchase-button').text()) - .toBe('sw-extension-store.component.sw-extension-buy-modal.purchaseButtonsLabels.free'); - }); - - it('renders purchase button label for rent variant', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'rent', - netPrice: 100, - discountCampaign: null, - }], - }); - - expect(wrapper.find('.sw-extension-buy-modal__purchase-button').text()) - .toBe('sw-extension-store.component.sw-extension-buy-modal.purchaseButtonsLabels.rent'); - }); - - it('renders purchase button label for buy variant', async () => { - const wrapper = await createWrapper({ - variants: [{ - id: 78674, - type: 'buy', - netPrice: 100, - discountCampaign: null, - }], - }); - - expect(wrapper.find('.sw-extension-buy-modal__purchase-button').text()) - .toBe('sw-extension-store.component.sw-extension-buy-modal.purchaseButtonsLabels.buy'); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/index.js deleted file mode 100644 index 48ad3cef..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import template from './sw-extension-listing-card.html.twig'; -import './sw-extension-listing-card.scss'; - -/** - * @private - */ -export default { - template, - - inject: [ - 'extensionStoreService', - ], - - props: { - extension: { - type: Object, - required: true, - }, - }, - - computed: { - assetFilter() { - return Shopware.Filter.getByName('asset'); - }, - - calculatedPrice() { - return this.extensionStoreService.getCalculatedPrice(this.recommendedVariant); - }, - - calculatedPriceSnippet() { - return this.extensionStoreService.getCalculatedPriceSnippet(this.extension.variants); - }, - - hasActiveDiscount() { - return this.extensionStoreService.isExtensionDiscounted(this.extension.variants); - }, - - isFree() { - return this.extensionStoreService.isVariantOfTypeFree(this.recommendedVariant); - }, - - isInstalled() { - return !!Shopware.Store.get('shopwareExtensions').myExtensions.data.some((installedExtension) => { - return installedExtension.installedAt && installedExtension.name === this.extension.name; - }); - }, - - isLicensed() { - const extension = Shopware.Store.get('shopwareExtensions').myExtensions.data - .find((installedExtension) => installedExtension.name === this.extension.name); - - if (extension === undefined) { - return false; - } - - return !!extension.storeLicense; - }, - - priceClass() { - return { - 'sw-extension-listing-card__info-price-discounted': this.hasActiveDiscount, - }; - }, - - recommendedVariant() { - return this.extensionStoreService.getRecommendedVariant(this.extension.variants); - }, - }, - - methods: { - openDetailPage() { - this.$router.push({ - name: 'sw.extension.store.detail', - params: { id: this.extension.id.toString() }, - }); - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.html.twig deleted file mode 100644 index a5ec33d4..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.html.twig +++ /dev/null @@ -1,81 +0,0 @@ -{% block sw_extension_listing_card %} -
- {% block sw_extension_listing_card_content %} - {% block sw_extension_listing_card_info_grid %} -
- {% block sw_extension_listing_card_info_icon %} - - {% endblock %} - - {% block sw_extension_listing_card_info_name %} -

- {{ extension.label }} -

- {% endblock %} - - {% block sw_extension_listing_card_info_rating %} -
- - - - {{ extension.numberOfRatings }} - -
- {% endblock %} - - {% block sw_extension_listing_card_label_display %} - - {% endblock %} - - {% block sw_extension_listing_card_info_description %} -

- {{ extension.shortDescription }} -

- {% endblock %} - - {% block sw_extension_listing_card_info_price %} -
- - - - - - - -
- {% endblock %} -
- {% endblock %} - {% endblock %} -
-{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.scss deleted file mode 100644 index 7f25718c..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.scss +++ /dev/null @@ -1,142 +0,0 @@ -@import "~scss/variables"; -@import "~scss/mixins"; - -.sw-extension-listing-card { - display: grid; - grid-template-rows: auto 1fr; - overflow: hidden; - cursor: pointer; - border-radius: $border-radius-lg; - transition: box-shadow .15s ease; - - @include drop-shadow-default; - - &:hover { - box-shadow: 0 3px 6px 0 var(--color-elevation-shadow-default); - - .sw-extension-listing-card__info-name { - color: var(--color-text-brand-default); - } - } - - &__extension-type-label { - position: absolute; - bottom: 8px; - right: 8px; - } - - .sw-extension-listing-card__info-grid { - background: var(--color-elevation-surface-raised); - padding: 16px; - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto auto auto 1fr; - grid-row-gap: 4px; - grid-template-areas: - "icon" - "name" - "rating" - "label-display" - "description" - "price"; - height: 254px; - - .sw-extension-listing-card__info-icon { - border-radius: 8px; - grid-area: icon; - height: 56px; - margin-bottom: 16px; - width: 56px; - } - - .sw-extension-listing-card__info-name { - grid-area: name; - font-size: $font-size-xs; - font-weight: $font-weight-semi-bold; - line-height: $line-height-xs; - margin-bottom: 2px; - transition: color .15s ease; - - @include truncate; - } - - .sw-extension-listing-card__info-rating { - grid-area: rating; - display: flex; - align-items: center; - font-size: $font-size-xxs; - - & > * { - margin-right: 1ex; - } - - .sw-extension-rating-stars__star { - cursor: pointer; - } - - &-count { - color: var(--color-text-secondary-default); - margin-top: 2px; - } - } - - .sw-extension-listing-card__label-display { - grid-area: label-display; - margin-bottom: 4px; - margin-top: 4px; - } - - .sw-extension-listing-card__info-description { - $line-height-description: $line-height-xs; - - grid-area: description; - font-size: $font-size-xxs; - line-height: $line-height-description; - height: calc($line-height-description * 2); - overflow: hidden; - position: relative; - margin-bottom: 4px; - - &::after { - content: ''; - display: block; - height: $line-height-description; - background: linear-gradient(90deg, transparent, var(--color-elevation-surface-raised)); - width: 40%; - position: absolute; - right: 0; - bottom: 0; - } - } - - .sw-extension-listing-card__info-price { - color: var(--color-text-secondary-default); - grid-area: price; - font-size: $font-size-xxs; - - strong { - color: var(--color-text-primary-default); - font-size: $font-size-xs; - font-weight: $font-weight-semi-bold; - } - - &-badge-sale { - background-color: var(--color-background-critical-default); - border-radius: $border-radius-pill; - color: var(--color-text-critical-default); - display: inline-block; - font-weight: $font-weight-medium; - height: $line-height-xs; - line-height: $line-height-xs; - margin-right: 2px; - padding: 0 5px; - } - - &-discounted { - strong { - color: var(--color-text-critical-default); - } - } - } - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.spec.js deleted file mode 100644 index 41d24cdc..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-listing-card/sw-extension-listing-card.spec.js +++ /dev/null @@ -1,343 +0,0 @@ -import { mount } from '@vue/test-utils'; -import ShopwareExtensionService from 'src/module/sw-extension/service/shopware-extension.service'; -import ShopwareDiscountCampaignService from 'src/app/service/discount-campaign.service'; -import ExtensionStoreService from 'SwagExtensionStore/module/sw-extension-store/service/extension-store.service'; -import 'src/module/sw-extension/store/extensions.store'; - -Shopware.Component.register( - 'sw-extension-listing-card', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-listing-card'), -); - -async function createWrapper(extension) { - return mount(await Shopware.Component.build('sw-extension-listing-card'), { - props: { - extension, - }, - global: { - stubs: { - 'sw-extension-icon': true, - 'sw-extension-rating-stars': true, - 'router-link': true, - 'sw-extension-type-label': true, - 'sw-extension-store-label-display': true, - }, - provide: { - repositoryFactory: { - create: () => { - return {}; - }, - }, - systemConfigApiService: { - getValues: () => { - return Promise.resolve({ - 'core.store.apiUri': 'https://api.shopware.com', - 'core.store.licenseHost': 'sw6.test.shopware.in', - 'core.store.shopSecret': 'very.s3cret', - 'core.store.shopwareId': 'max@muster.com', - }); - }, - }, - extensionStoreService: new ExtensionStoreService( - new ShopwareDiscountCampaignService(), - new ShopwareExtensionService( - undefined, - undefined, - new ShopwareDiscountCampaignService(), - ), - ), - }, - }, - }); -} - -describe('sw-extension-listing-card', () => { - /** @type Wrapper */ - let wrapper; - - beforeEach(() => { - Shopware.Store.get('shopwareExtensions').setMyExtensions([{ - name: 'Test', - installedAt: null, - }]); - }); - - it('should be a Vue.JS component', async () => { - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test', - labels: [], - variants: [ - { - id: 79102, - type: 'free', - netPrice: 0, - netPricePerMonth: 0, - trialPhaseIncluded: true, - }, - ], - }); - - expect(wrapper.vm).toBeTruthy(); - }); - - it('isFree should be false when extension is not free', async () => { - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test', - labels: [], - variants: [ - { - id: 79102, - type: 'rent', - netPrice: 19, - netPricePerMonth: 19, - trialPhaseIncluded: true, - }, - ], - }); - - expect(wrapper.vm.isFree).toBe(false); - }); - - it('isFree should be true when extension is free', async () => { - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test', - labels: [], - variants: [ - { - id: 79102, - type: 'free', - netPrice: 0, - netPricePerMonth: 0, - trialPhaseIncluded: true, - }, - ], - }); - - expect(wrapper.vm.isFree).toBe(true); - }); - - it('isInstalled should be false when extension is not in store', async () => { - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test', - labels: [], - variants: [ - { - id: 79102, - type: 'free', - netPrice: 0, - netPricePerMonth: 0, - trialPhaseIncluded: true, - }, - ], - }); - - expect(wrapper.vm.isInstalled).toBe(false); - }); - - it('isInstalled should be true when extension is in store', async () => { - Shopware.Store.get('shopwareExtensions').setMyExtensions([{ - name: 'Test', - installedAt: 'some date', - }]); - - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test', - labels: [], - variants: [ - { - id: 79102, - type: 'free', - netPrice: 0, - netPricePerMonth: 0, - trialPhaseIncluded: true, - }, - ], - }); - - expect(wrapper.vm.isInstalled).toBe(true); - }); - - it('isLicense should be undefined when not found', async () => { - wrapper = await createWrapper({ - label: 'Test', - name: 'Test2', - labels: [], - variants: [], - }); - - expect(wrapper.vm.isLicensed).toBe(false); - }); - - it('recommendedVariant should be the one with the lowest price per month when multiple variants given', async () => { - const expectedVariant = { - id: 79102, - type: 'rent', - netPrice: 199, - netPricePerMonth: 16.58, - duration: 12, - trialPhaseIncluded: true, - }; - - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test', - labels: [], - variants: [ - { - id: 79101, - type: 'rent', - netPrice: 19, - netPricePerMonth: 19, - duration: 1, - trialPhaseIncluded: true, - }, - expectedVariant, - ], - }); - - expect(wrapper.vm.recommendedVariant).toEqual(expectedVariant); - }); - - it('openDetailPage calls router', async () => { - wrapper = await createWrapper({ - id: 1, - label: 'Test', - name: 'Test2', - labels: [], - variants: [{ - id: 10, - type: 'rent', - netPrice: 19, - netPricePerMonth: 19, - duration: 1, - trialPhaseIncluded: true, - extensions: [], - }], - }); - - wrapper.vm.$router = { - push: jest.fn(), - }; - - wrapper.vm.openDetailPage(); - - expect(wrapper.vm.$router.push).toHaveBeenCalled(); - }); - - it('should display one-time price for buying', async () => { - wrapper = await createWrapper({ - label: 'A Label', - shortDescription: 'A short description', - labels: [], - variants: [{ - id: 11, - type: 'buy', - netPrice: 19, - netPricePerMonth: null, - duration: null, - trialPhaseIncluded: true, - extensions: [], - }], - rating: 4, - numberOfRatings: 10, - }); - - expect(wrapper.find('.sw-extension-listing-card__info-name').text()).toBe('A Label'); - expect(wrapper.find('.sw-extension-listing-card__info-description').text()).toBe('A short description'); - expect(wrapper.find('.sw-extension-listing-card__info-rating-count').text()).toBe('10'); - expect(wrapper.find('.sw-extension-listing-card__info-price span').text()) - .toBe('sw-extension-store.general.labelPriceOneTime'); - }); - - it('should display normal prices for renting', async () => { - wrapper = await createWrapper({ - label: 'A Label', - shortDescription: 'A short description', - labels: [], - variants: [{ - id: 11, - type: 'rent', - netPrice: 19, - netPricePerMonth: 19, - duration: 1, - trialPhaseIncluded: true, - extensions: [], - }], - rating: 4, - numberOfRatings: 10, - }); - - expect(wrapper.find('.sw-extension-listing-card__info-name').text()).toBe('A Label'); - expect(wrapper.find('.sw-extension-listing-card__info-description').text()).toBe('A short description'); - expect(wrapper.find('.sw-extension-listing-card__info-rating-count').text()).toBe('10'); - expect(wrapper.find('.sw-extension-listing-card__info-price span').text()) - .toBe('sw-extension-store.general.labelPricePerMonth'); - }); - - it('should display a discount for renting', async () => { - wrapper = await createWrapper({ - id: 1, - localId: null, - name: 'Sample Extension', - label: 'Sample Extension Label', - description: null, - shortDescription: 'Sample Extension description', - producerName: null, - license: null, - version: null, - latestVersion: null, - privacyPolicyLink: null, - languages: [], - rating: 3, - numberOfRatings: 10, - labels: [], - variants: [{ - id: 11, - type: 'rent', - netPrice: 19, - netPricePerMonth: 19, - duration: 1, - trialPhaseIncluded: true, - discountCampaign: { - name: 'Einführungspreis', - discount: 15, - discountedPrice: 16.15, - discountedPricePerMonth: 16.15, - startDate: '2021-01-27T00:01:00+01:00', - discountAppliesForMonths: null, - }, - extensions: [], - }], - images: [{ - remoteLink: 'https://example.com', - raw: null, - extensions: [], - }], - icon: null, - iconRaw: null, - active: false, - type: 'plugin', - isTheme: false, - extensions: [], - }); - - expect(wrapper.find('.sw-extension-listing-card__info-name').text()).toBe('Sample Extension Label'); - expect(wrapper.find('.sw-extension-listing-card__info-description').text()).toBe('Sample Extension description'); - expect(wrapper.find('.sw-extension-listing-card__info-rating-count').text()).toBe('10'); - expect(wrapper.find('.sw-extension-listing-card__info-price-badge-sale').text()) - .toBe('sw-extension-store.general.labelSale'); - expect(wrapper.find('.sw-extension-listing-card__info-price-discounted').text()) - .toBe('sw-extension-store.general.labelPricePerMonth'); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/index.js deleted file mode 100644 index 07ae680f..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import template from './sw-extension-store-accessibility.html.twig'; -import './sw-extension-store-accessibility.scss'; - -export default Shopware.Component.wrapComponentConfig({ - template, -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/sw-extension-store-accessibility.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/sw-extension-store-accessibility.html.twig deleted file mode 100644 index a5c88416..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/sw-extension-store-accessibility.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -
- - {{ $t('sw-extension-store-accessibility.accessibility-text') }} - - - - {{ $t('sw-extension-store-accessibility.accessibility-more-information') }} - -
diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/sw-extension-store-accessibility.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/sw-extension-store-accessibility.scss deleted file mode 100644 index e22195b5..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-accessibility/sw-extension-store-accessibility.scss +++ /dev/null @@ -1,19 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-accessibility { - display: grid; - grid-template-columns: max-content 1fr; - background: linear-gradient(#003075, #0156d0); - padding: 16px 20px; - border-radius: $border-radius-lg; - margin-bottom: 20px; - - &__info { - color: var(--color-text-static-default); - align-self: center; - } - - &__link { - justify-self: end; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/index.js deleted file mode 100644 index 8d2e6ea8..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import template from './sw-extension-store-error-card.html.twig'; -import './sw-extension-store-error-card.scss'; - -/** - * @private - */ -export default { - template, - - props: { - title: { - type: String, - required: false, - }, - variant: { - type: String, - required: false, - default: 'neutral', - validator(value) { - return ['info', 'danger', 'success', 'warning', 'neutral'].includes(value); - }, - }, - }, - - computed: { - iconName() { - switch (this.variant) { - case 'danger': - return 'regular-times-circle'; - case 'info': - return 'regular-info-circle'; - case 'warning': - return 'regular-exclamation-circle'; - case 'success': - return 'regular-check-circle'; - default: - return 'regular-info-circle'; - } - }, - - componentClasses() { - return [ - `sw-extension-store-error-card--variant-${this.variant}`, - ]; - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.html.twig deleted file mode 100644 index 504ef48e..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.html.twig +++ /dev/null @@ -1,43 +0,0 @@ -{% block sw_extension_store_error_card %} - - {% block sw_extension_store_error_card_label %} - - - {% block sw_extension_store_error_card_icon %} - - {% endblock %} - - - {% endblock %} - - {% block sw_extension_store_error_card_title %} -

- {% endblock %} - - {% block sw_extension_store_error_card_message %} -
- -
- {% endblock %} - - {% block sw_extension_store_error_card_actions %} -
- -
- {% endblock %} - -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.scss deleted file mode 100644 index 82d6a7a7..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.scss +++ /dev/null @@ -1,41 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-error-card { - - .sw-meteor-card__content-wrapper { - min-height: 430px; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - text-align: center; - } - - h2.sw-extension-store-error-card__title { - font-size: $font-size-m; - } - - & &__label { - width: 72px; - height: 72px; - display: flex; - justify-content: center; - align-items: center; - margin: 0 0 20px 0; - - .mt-label__caption { - width: auto; - } - } - - .mt-button { - margin-top: 12px; - } - - p { - color: var(--color-text-secondary-default); - font-size: $font-size-xs; - max-width: 325px; - margin-bottom: 1.5rem; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.spec.js deleted file mode 100644 index c3e815d5..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-error-card/sw-extension-store-error-card.spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { mount } from '@vue/test-utils'; - -Shopware.Component.register( - 'sw-extension-store-error-card', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-error-card'), -); - -async function createWrapper(opts) { - return mount(await Shopware.Component.build('sw-extension-store-error-card'), { - ...opts, - global: { - renderStubDefaultSlot: true, - stubs: { - 'sw-meteor-card': { - template: '
', - }, - 'sw-label': true, - 'sw-color-badge': true, - }, - }, - }); -} - -describe('sw-extension-store-error-card', () => { - it('should render title', async () => { - const wrapper = await createWrapper({ - props: { - title: 'Please update your plugin.', - }, - }); - - expect(wrapper.get('.sw-extension-store-error-card__title').text()).toBe('Please update your plugin.'); - }); - - it('should render message in main slot', async () => { - const wrapper = await createWrapper({ - slots: { - default: 'A new version of the plugin is available.', - }, - }); - - expect(wrapper.get('.sw-extension-store-error-card__message').text()) - .toBe('A new version of the plugin is available.'); - }); - - it('should render content in actions slot', async () => { - const wrapper = await createWrapper({ - slots: { - actions: 'Check for updates', - }, - }); - - expect(wrapper.get('.sw-extension-store-error-card__actions .mt-button').exists()).toBe(true); - expect(wrapper.get('.sw-extension-store-error-card__actions .mt-button').text()).toBe('Check for updates'); - }); - - describe('render correct icons and color variants', () => { - const testCases = [ - { - variant: undefined, - expectedIcon: 'regular-info-circle', - expectedLabelVariant: 'neutral', - expectedComponentClass: 'sw-extension-store-error-card--variant-neutral', - }, - { - variant: 'info', - expectedIcon: 'regular-info-circle', - expectedLabelVariant: 'info', - expectedComponentClass: 'sw-extension-store-error-card--variant-info', - }, - { - variant: 'danger', - expectedIcon: 'regular-times-circle', - expectedLabelVariant: 'danger', - expectedComponentClass: 'sw-extension-store-error-card--variant-danger', - }, - { - variant: 'success', - expectedIcon: 'regular-check-circle', - expectedLabelVariant: 'success', - expectedComponentClass: 'sw-extension-store-error-card--variant-success', - }, - { - variant: 'warning', - expectedIcon: 'regular-exclamation-circle', - expectedLabelVariant: 'warning', - expectedComponentClass: 'sw-extension-store-error-card--variant-warning', - }, - ]; - - it.each(testCases)('%p', async ({ variant, expectedIcon, expectedLabelVariant, expectedComponentClass }) => { - const wrapper = await createWrapper({ - props: { - variant, - }, - }); - - // Ensure correct label variant - expect(wrapper.get('.sw-extension-store-error-card__label').attributes().variant).toBe(expectedLabelVariant); - - // Ensure correct icon for variant - expect(wrapper.get('.sw-extension-store-error-card__label .mt-icon').classes()) - .toContain(`icon--${expectedIcon}`); - - // Ensure correct component class - expect(wrapper.classes()).toContain(expectedComponentClass); - }); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.html.twig index 0e69fd7f..1a3072ef 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.html.twig +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.html.twig @@ -1,5 +1,5 @@ - {{ $t('sw-extension-store.detail.inAppPurchases.paymentOptions') }} + {{ $t('sw-extension-store.in-app-purchases.paymentOptions') }}
    diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.spec.js index 110a47f0..f8932eb8 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.spec.js +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-in-app-purchases-listing-modal/sw-extension-store-in-app-purchases-listing-modal.spec.js @@ -14,11 +14,11 @@ async function createWrapper(propsData = {}) { global: { stubs: { 'sw-modal': { - template: `
    `, + template: '
    ', }, 'mt-icon': true, 'sw-collapse': { - template: `
    `, + template: '
    ', }, }, }, diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/index.js deleted file mode 100644 index f070b0fa..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import template from './sw-extension-store-label-display.html.twig'; -import './sw-extension-store-label-display.scss'; - -/** - * @private - */ -export default { - template, - - props: { - labels: { - type: Array, - required: true, - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.html.twig deleted file mode 100644 index d02699e7..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.html.twig +++ /dev/null @@ -1,13 +0,0 @@ -{% block sw_extension_label_display %} -
    - {% block sw_extension_label_display_label %} - - {{ item.label }} - - {% endblock %} -
    -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.scss deleted file mode 100644 index bc3f593a..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.scss +++ /dev/null @@ -1,5 +0,0 @@ -.sw-extension-store-label-display { - display: flex; - flex-wrap: wrap; - gap: 8px; -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.spec.js deleted file mode 100644 index 28f80731..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label-display/sw-extension-store-label-display.spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import { mount } from '@vue/test-utils'; - -Shopware.Component.register( - 'sw-extension-store-label-display', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-label-display'), -); -Shopware.Component.register( - 'sw-extension-label', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-label'), -); - - -async function createWrapper(labels) { - return mount(await Shopware.Component.build('sw-extension-store-label-display'), { - props: { - labels, - }, - global: { - stubs: { - 'sw-extension-label': await Shopware.Component.build('sw-extension-label'), - }, - }, - }); -} - -describe('sw-extension-store-label-display', () => { - it('should be a Vue.JS component', async () => { - const wrapper = await createWrapper([{ - label: 'Gold zerifiziert', - color: '#cbac44', - }]); - - expect(wrapper.vm).toBeTruthy(); - }); - - it('should display the labels with the correct background', async () => { - const wrapper = await createWrapper([{ - label: 'Label Text 0', - color: '#cbac44', - }, - { - label: 'Label Text 1', - }, - { - label: 'Label Text 2', - color: '#000', - }]); - - const label = wrapper.findAll('.sw-extension-label'); - - expect(label.at(0).attributes().style).toBe('background-color: rgb(203, 172, 68); color: rgb(255, 255, 255);'); - expect(label.at(0).text()).toBe('Label Text 0'); - - expect(label.at(1).attributes().style).toBe('background-color: rgba(41, 51, 61, 0.749); color: rgb(255, 255, 255);'); - expect(label.at(1).text()).toBe('Label Text 1'); - - expect(label.at(2).attributes().style).toBe('background-color: rgb(0, 0, 0); color: rgb(255, 255, 255);'); - expect(label.at(2).text()).toBe('Label Text 2'); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/index.js deleted file mode 100644 index 0740b298..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/index.js +++ /dev/null @@ -1,34 +0,0 @@ -import template from './sw-extension-store-label.html.twig'; -import './sw-extension-store-label.scss'; - -/** - * @private - */ -export default { - template, - - props: { - backgroundColor: { - type: String, - required: false, - // matches sass variable $color-darkgray-600 - default: '#29333dbf', - }, - }, - - methods: { - determineTextColor(backgroundColor) { - if (!backgroundColor) { - return '#000'; - } - - const hexColor = backgroundColor.charAt(0) === '#' ? backgroundColor.substring(1, 7) : backgroundColor; - - const r = parseInt(hexColor.substring(0, 2), 16); // hexToR - const g = parseInt(hexColor.substring(2, 4), 16); // hexToG - const b = parseInt(hexColor.substring(4, 6), 16); // hexToB - - return (r * 0.299 + g * 0.587 + b * 0.114) > 186 ? '#000' : '#fff'; - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/sw-extension-store-label.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/sw-extension-store-label.html.twig deleted file mode 100644 index 037678d2..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/sw-extension-store-label.html.twig +++ /dev/null @@ -1,7 +0,0 @@ -{% block sw_extension_label %} -
    - {% block sw_extension_label_slot %} - - {% endblock %} -
    -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/sw-extension-store-label.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/sw-extension-store-label.scss deleted file mode 100644 index b1ecaa2d..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-label/sw-extension-store-label.scss +++ /dev/null @@ -1,12 +0,0 @@ -@import "~scss/variables"; - -$sw-label-pill-border-radius: 50px; - -.sw-extension-label { - display: inline-block; - font-size: $font-size-xxs; - font-weight: $font-weight-medium; - letter-spacing: 0.08px; - padding: 2px 5px; - border-radius: $border-radius-default; -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/index.js deleted file mode 100644 index 26f745a6..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/index.js +++ /dev/null @@ -1,208 +0,0 @@ -import template from './sw-extension-store-listing-filter.html.twig'; -import './sw-extension-store-listing-filter.scss'; - -const { Criteria } = Shopware.Data; - -export default { - template, - - mixins: [ - 'notification', - ], - - inject: ['extensionStoreDataService'], - - data() { - return { - isLoading: true, - listingFilters: [], - listingSorting: {}, - }; - }, - - computed: { - search() { - return Shopware.Store.get('shopwareExtensions').search; - }, - - activeFilters: { - get() { - return Shopware.Store.get('shopwareExtensions').search.filter; - }, - set(newFilter) { - Shopware.Store.get('shopwareExtensions').search.filter = newFilter; - }, - }, - - sortingOptions() { - if (!this.listingSorting.options) { - return []; - } - - return this.listingSorting.options.map(option => { - option.orderIdentifier = `${option.orderBy}##${option.orderSequence}`; - - return option; - }); - }, - - defaultSortingValue() { - if (!this.listingSorting.default) { - return null; - } - - return `${this.listingSorting.default.orderBy}##${this.listingSorting.default.orderSequence}`; - }, - - sortingValue() { - const field = this.search.sorting && this.search.sorting.field; - const order = this.search.sorting && this.search.sorting.order; - - if (!field || !order) { - return this.defaultSortingValue; - } - - // Sorting value contains field and order which are seperated by two # - return `${field}##${order}`; - }, - - listingFiltersSorted() { - // avoid any manipulations to references - const listingFiltersCopy = Shopware.Utils.object.cloneDeep(this.listingFilters); - - // sort filters - listingFiltersCopy.sort((a, b) => a.position - b.position); - - // sort options in each filter - listingFiltersCopy.forEach(filter => { - filter.options.sort((a, b) => a.position - b.position); - }); - - // special case for categories - add children behind parents - const categoryFilter = listingFiltersCopy.find(filter => filter.type === 'category'); - - if (categoryFilter) { - categoryFilter.options = this.getOrderedCategories(categoryFilter.options); - } - - return listingFiltersCopy; - }, - }, - - created() { - this.createdComponent(); - }, - - methods: { - createdComponent() { - this.fetchListingFilters(); - }, - - fetchListingFilters() { - return this.extensionStoreDataService.listingFilters() - .then(({ filter, sorting }) => { - this.listingFilters = filter; - this.listingSorting = sorting; - }) - .catch((e) => { - this.createNotificationError({ - message: e, - }); - }) - .finally(() => { - this.isLoading = false; - }); - }, - - getValueForFilter(filter) { - return this.activeFilters[filter.name] || null; - }, - - changeValueForFilter(filter, value) { - if (!value) { - this.$delete(this.activeFilters, filter.name); - return; - } - - this.activeFilters[filter.name] = value; - }, - - getOptionsForFilter(filter) { - const anyOption = { - label: this.$t('sw-extension.store.listing.anyOption'), - value: null, - }; - - return [ - anyOption, - ...filter.options, - ]; - }, - - setSelectedSorting(orderIdentifier) { - // Sorting value contains field and order which are seperated by two # - // Here we extract both values - const [field, order] = orderIdentifier.split('##'); - - Shopware.Store.get('shopwareExtensions').setSearchValue( - { key: 'sorting', value: Criteria.sort(field, order) }, - ); - }, - - isRootCategory(category) { - return category.parent === null || typeof category.parent === 'undefined'; - }, - - categoryDepth(category) { - let depth = 0; - let currentParent = this.getCategoryByName(category.parent); - - while (currentParent) { - depth += 1; - currentParent = this.getCategoryByName(currentParent.parent) ? - this.getCategoryByName(currentParent.parent) : - null; - } - - return depth; - }, - - getCategoryByName(name) { - const categoryFilter = this.listingFilters.find(filter => filter.type === 'category'); - return categoryFilter.options.find((category) => { - return category.label === name - || category.value === name - || category.name === name; - }); - }, - - getOrderedCategories(categories) { - const lookup = new Map(); - - // add a root - lookup.set(null, { value: null, children: [] }); - - // add all nodes to lookup - categories.forEach((option) => { - lookup.set(option.value, { value: option, children: [] }); - }); - - // link children - categories.forEach((option) => { - lookup.get(option.parent).children.push(lookup.get(option.value)); - }); - - // denormalize lookup - return this.flatTree(lookup.get(null)); - }, - - flatTree(root) { - const flat = root.value ? [root.value] : []; - - root.children.sort((a, b) => a.value.position - b.value.position) - .forEach((child) => flat.push(...this.flatTree(child))); - - return flat; - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.html.twig deleted file mode 100644 index 120138e4..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.html.twig +++ /dev/null @@ -1,50 +0,0 @@ -{% block sw_extension_store_listing_filter %} -
    - - -
    -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.scss deleted file mode 100644 index 6d48a113..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.scss +++ /dev/null @@ -1,8 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-listing-filter { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-bottom: 40px; -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.spec.js deleted file mode 100644 index 6732f250..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-listing-filter/sw-extension-store-listing-filter.spec.js +++ /dev/null @@ -1,397 +0,0 @@ -import { mount } from '@vue/test-utils'; - -Shopware.Component.register( - 'sw-extension-store-listing-filter', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-listing-filter'), -); - -async function createWrapper() { - return mount(await Shopware.Component.build('sw-extension-store-listing-filter'), { - global: { - stubs: { - 'sw-loader': true, - 'sw-meteor-single-select': { - props: ['options'], - template: '
    ', - }, - }, - provide: { - extensionStoreDataService: { - listingFilters: () => { - return Promise.resolve({ - filter: [ - { - type: 'category', - name: 'category', - label: 'Category', - position: 1, - options: [ - { - name: 'category', - value: 'TempPromotion', - label: 'Sale', - position: 0, - parent: null, - }, - { - name: 'category', - value: 'TempPromotion_Misc', - label: 'Other', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'TempPromotion_Themes', - label: 'Themes', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'TempPromotion_SEOOptimierung', - label: 'SEO Optimization', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'TempPromotion_Bestellprozess', - label: 'Checkout process', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'TempPromotion_KundenkontoPersonalisierung', - label: 'Customer account + personalization', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'TempPromotion_ConversionOptimierung', - label: 'Conversion Optimization', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'TempPromotion_B2BExtensions', - label: 'B2B extensions', - position: 0, - parent: 'TempPromotion', - }, - { - name: 'category', - value: 'Extensions', - label: 'Extensions', - position: 0, - parent: null, - }, - { - name: 'category', - value: 'ValentinesDaySale', - label: "Valentine's Day Sale", - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Covid-19-PluginsForSupport', - label: 'Covid-19 - Plugins for support', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Integration', - label: 'Integration', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'StorefrontDetailanpassungen', - label: 'Frontend / detail adjustment', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'GitHub', - label: 'GitHub', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'TheBestSEOTools', - label: 'The best SEO tools', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'B2BExtensions', - label: 'B2B extensions', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'MarketingTools', - label: 'Marketing-Tools', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'ConversionOptimierung', - label: 'Conversion Optimization', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Einkaufswelten', - label: 'Shopping Experiences', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'MigrationTools', - label: 'Migration tools', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'KundenkontoPersonalisierung', - label: 'Customer account + personalization', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Bestellprozess', - label: 'Checkout process', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Sprache', - label: 'Language & Internationalisation', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'PreissuchmaschinenPortale', - label: 'Price search engine / portal', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Auswertung', - label: 'Evaluation and Analysis', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'Administration', - label: 'Administration', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'SEOOptimierung', - label: 'SEO Optimization', - position: 0, - parent: 'Extensions', - }, - { - name: 'category', - value: 'ProductLicense', - label: 'Licenses', - position: 0, - parent: null, - }, - { - name: 'category', - value: 'Themes', - label: 'Themes', - position: 0, - parent: null, - }, - { - name: 'category', - value: 'Branche', - label: 'Industry', - position: 0, - parent: 'Themes', - }, - ], - }, - { - type: 'rating', - name: 'rating', - label: 'Rating', - position: 2, - options: [ - { name: 'rating', value: '5', label: 'Min 5 stars', position: 1 }, - { name: 'rating', value: '4', label: 'Min 4 stars', position: 2 }, - { name: 'rating', value: '3', label: 'Min 3 stars', position: 3 }, - { name: 'rating', value: '2', label: 'Min 2 stars', position: 4 }, - { name: 'rating', value: '1', label: 'Min 1 star', position: 5 }, - ], - }, - { - type: 'multi-select', - name: 'certification', - label: 'Certification', - position: 3, - options: [ - { name: 'certification', value: 'gold', label: 'Gold', position: 1 }, - { name: 'certification', value: 'silver', label: 'Silver', position: 2 }, - { name: 'certification', value: 'bronze', label: 'Bronze', position: 3 }, - ], - }, - { - type: 'multi-select', - name: 'variants', - label: 'Payment', - position: 4, - options: [ - { name: 'variants', value: 'buy', label: 'Buy', position: 1 }, - { name: 'variants', value: 'rent', label: 'Rent', position: 2 }, - { name: 'variants', value: 'free', label: 'Free', position: 3 }, - ], - }, - { - type: 'multi-select', - name: 'other', - label: 'Other', - position: 5, - options: [ - { name: 'support', value: '1', label: 'Includes Support', position: 1 }, - { name: 'test', value: '1', label: 'Includes Support', position: 2 }, - ], - }, - ], - sorting: { - default: { - orderBy: 'popularity', - orderSequence: 'desc', - label: 'Popularity', - position: 5, - }, - options: [ - { - orderBy: 'name', - orderSequence: 'asc', - label: 'Name A-Z', - position: 1, - orderIdentifier: 'name##asc', - }, - { - orderBy: 'name', - orderSequence: 'desc', - label: 'Name Z-A', - position: 2, - orderIdentifier: 'name##desc', - }, - { - orderBy: 'releaseDate', - orderSequence: 'desc', - label: 'Release date', - position: 3, - orderIdentifier: 'releaseDate##desc', - }, - { - orderBy: 'rating', - orderSequence: 'desc', - label: 'Rating', - position: 4, - orderIdentifier: 'rating##desc', - }, - { - orderBy: 'popularity', - orderSequence: 'desc', - label: 'Popularity', - position: 5, - orderIdentifier: 'popularity##desc', - }, - ], - }, - }); - }, - }, - }, - }, - }); -} - -describe('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-listing-filter', () => { - let wrapper; - - beforeAll(() => { - Shopware.Store.register({ - id: 'shopwareExtensions', - state: () => ({ - search: { - page: 1, - limit: 12, - rating: null, - sorting: null, - term: null, - filter: {}, - }, - }), - }); - }); - - beforeEach(async () => { - wrapper = await createWrapper(); - }); - - it('should be a Vue.JS component', async () => { - expect(wrapper.vm).toBeTruthy(); - }); - - it('should render the sorting', async () => { - const sorting = wrapper.getComponent('.sw-extension-store-listing-filter__sorting'); - - const options = wrapper.vm.sortingOptions; - const propOptions = sorting.props('options'); - expect(propOptions).toEqual(options); - - propOptions.forEach(option => { - const orderIdentifierExpect = `${option.orderBy}##${option.orderSequence}`; - expect(option.orderIdentifier).toEqual(orderIdentifierExpect); - }); - }); - - it('should render a meteor single select for each filter', async () => { - const filters = wrapper.findAllComponents('.sw-extension-store-listing-filter__filters'); - - expect(filters).toHaveLength(5); - - wrapper.vm.listingFiltersSorted.forEach((filter, index) => { - const selectFilter = filters.at(index); - - expect(selectFilter.props('options')).toEqual([ - { - label: 'sw-extension.store.listing.anyOption', - value: null, - }, - ...filter.options, - ]); - }); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/index.ts b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/index.ts new file mode 100644 index 00000000..739816b6 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/index.ts @@ -0,0 +1,92 @@ +import template from './sw-extension-store-purchase-confirmation-checkout-overview.html.twig'; +import './sw-extension-store-purchase-confirmation-checkout-overview.scss'; + +import type { ExtensionStoreBasket, ExtensionStorePaymentMean } from '../../types/extension-store-basket.types'; + +/** + * @private + */ +export default Shopware.Component.wrapComponentConfig({ + template, + + props: { + cart: { + type: Object as PropType, + required: true, + }, + defaultPaymentMean: { + type: Object as PropType, + required: true, + }, + editable: { + type: Boolean, + required: false, + default: true, + }, + hasPaymentMethodError: { + type: Boolean, + required: false, + default: false, + }, + isDemoShop: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + showPaymentMethodBanner: this.hasPaymentMethodError, + }; + }, + + computed: { + position() { + return this.cart?.positions?.[0]; + }, + + extension() { + return this.position?.extension; + }, + + producer() { + return this.extension?.producer; + }, + + bookingShop() { + return this.cart?.bookingShop; + }, + + bookingShopDomain() { + const domain = this.bookingShop?.domain ?? ''; + + if (!domain) { + return ''; + } + + // If the domain does not start with http:// or https://, prepend https:// + if (!/^https?:\/\//i.test(domain)) { + return `https://${domain}`; + } + + return domain; + }, + + billingAddress() { + return this.cart?.billingAddress; + }, + + cartHasBillingAddress() { + return this.billingAddress?.fullName + && this.billingAddress.street + && this.billingAddress.city + && this.billingAddress.country + && this.billingAddress.zip; + }, + + payment() { + return this.cart?.payment; + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/sw-extension-store-purchase-confirmation-checkout-overview.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/sw-extension-store-purchase-confirmation-checkout-overview.html.twig new file mode 100644 index 00000000..6ffc2a22 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/sw-extension-store-purchase-confirmation-checkout-overview.html.twig @@ -0,0 +1,194 @@ + {% block sw_extension_store_purchase_confirmation_checkout_overview %} +
    + {% block sw_extension_store_purchase_confirmation_checkout_overview_details %} +
    + +
    +
    + {{ extension.label }} +
    +
    + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.by') }} + + + {{ producer.name }} + + + + {{ producer.name }} + +
    +
    +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_overview_info %} +
    + {% block sw_extension_store_purchase_confirmation_checkout_overview_info_booking_domain %} +
    +
    + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.bookingDomain') }}: +
    +
    + + {{ bookingShop.domain }} + +
    +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_overview_info_billing_address %} +
    +
    + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.billingAddress') }}: +
    +
    +
    + {{ billingAddress.companyName }} +
    +
    +
    + {{ billingAddress.fullName }} +
    +
    + {{ billingAddress.street.replace(',', '') }}, {{ billingAddress.zip }} {{ billingAddress.city }} +
    +
    + {{ billingAddress.country }} +
    +
    +
    +
    + + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.change') }} + +
    +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_overview_info_payment_method %} +
    +
    + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.paymentMethod') }}: +
    +
    + + + +
    +
    + + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.change') }} + + + {{ $t('sw-extension-store.purchase-confirmation.checkout.overview.addPaymentMethod') }} + +
    +
    + {% endblock %} +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_overview_payment_method_banner %} + + + {% endblock %} +
    + {% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/sw-extension-store-purchase-confirmation-checkout-overview.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/sw-extension-store-purchase-confirmation-checkout-overview.scss new file mode 100644 index 00000000..714c77bf --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout-overview/sw-extension-store-purchase-confirmation-checkout-overview.scss @@ -0,0 +1,144 @@ +.sw-extension-store-purchase-confirmation__checkout-overview { + border: 1px solid var(--color-border-secondary-default); + border-radius: var(--border-radius-card); + display: flex; + flex-direction: column; + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + overflow: hidden; + + &-details { + align-items: center; + background: var(--color-background-secondary-default); + display: flex; + gap: 12px; + padding: 24px; + + &-icon { + border-radius: var(--border-radius-card); + height: 56px; + overflow: hidden; + width: 56px; + + &::after { + display: none; + } + } + + &-info { + &-name { + font-size: var(--font-size-s); + font-weight: var(--font-weight-bold); + line-height: var(--font-line-height-s); + } + + &-producer-name { + color: var(--color-text-secondary-default); + + .mt-link { + color: var(--color-text-primary-default); + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + } + } + + &-info { + display: flex; + flex-direction: column; + padding: 8px 24px; + + &-row { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + flex-direction: column; + padding: 12px 0; + + &:first-child { + border-top: none; + } + + @media screen and (min-width: 768px) { + flex-direction: row; + } + + &-label { + color: var(--color-text-secondary-default); + + @media screen and (min-width: 768px) { + width: 180px; + } + } + + &-value { + color: var(--color-text-primary-default); + font-weight: var(--font-weight-medium); + gap: 4px; + + &.is--payment-method { + align-items: center; + display: flex; + } + + &-address { + color: var(--color-text-secondary-default); + display: flex; + flex-direction: column; + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + } + + &-payment-method-label { + color: var(--color-text-secondary-default); + font-weight: var(--font-weight-regular); + } + + .sw-label { + margin: 0; + } + + .mt-link { + color: var(--color-text-primary-default); + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &-action { + @media screen and (min-width: 768px) { + margin-left: auto; + } + + .mt-link { + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + } + } + + &-payment-method-banner { + padding: var(--scale-size-8) var(--scale-size-24) var(--scale-size-20) var(--scale-size-24); + + .mt-banner { + margin: 0; + + .mt-button { + margin-top: var(--scale-size-12); + } + } + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/index.ts b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/index.ts new file mode 100644 index 00000000..27308fd5 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/index.ts @@ -0,0 +1,293 @@ +import extensionStorePurchaseConfirmationStore from '../../store/extension-store-purchase-confirmation.store'; +import template from './sw-extension-store-purchase-confirmation-checkout.html.twig'; +import './sw-extension-store-purchase-confirmation-checkout.scss'; + +import type { ExtensionStoreBasket, ExtensionStorePaymentMean } from '../../types/extension-store-basket.types'; + +const { Utils } = Shopware; + +/** + * @private + */ +export default Shopware.Component.wrapComponentConfig({ + template, + + props: { + cart: { + type: Object as PropType, + required: true, + }, + paymentMeans: { + type: Object as PropType, + required: true, + }, + tocAccepted: { + type: Boolean, + required: true, + }, + permissionsAccepted: { + type: Boolean, + required: true, + }, + installAfterPurchase: { + type: Boolean, + required: true, + }, + isDemoShop: { + type: Boolean, + required: true, + }, + isTestLicense: { + type: Boolean, + required: true, + }, + }, + + emits: [ + 'update:modal-view', + 'update:toc-accepted', + 'update:permissions-accepted', + 'update:install-after-purchase', + ], + + computed: { + steps() { + return Object.freeze({ + CHECKOUT: null, + SUCCESS: 'success', + FAILED: 'failed', + }); + }, + + step() { + if (extensionStorePurchaseConfirmationStore().isFailedOnBasketCreation) { + return this.steps.FAILED; + } + + if (this.isSubmitted && !this.isLoading) { + return extensionStorePurchaseConfirmationStore().isSuccessful ? this.steps.SUCCESS : this.steps.FAILED; + } + + return this.steps.CHECKOUT; + }, + + isOpen() { + return extensionStorePurchaseConfirmationStore().isOpen; + }, + + isLoading() { + return extensionStorePurchaseConfirmationStore().isLoading; + }, + + isSubmitted() { + return extensionStorePurchaseConfirmationStore().isSubmitted; + }, + errorTitle() { + return extensionStorePurchaseConfirmationStore().errorTitle; + }, + errorDescription() { + return extensionStorePurchaseConfirmationStore().errorDescription; + }, + errorDocumentationLink() { + return extensionStorePurchaseConfirmationStore().errorDocumentationLink; + }, + position() { + return this.cart?.positions?.[0]; + }, + + extension() { + return this.position?.extension; + }, + + permissions() { + return this.extension?.permissions; + }, + + domains() { + return this.extension?.domains.filter((domain) => domain !== null); + }, + + defaultPaymentMean() { + const cartPaymentMeanId = this.cart?.payment?.paymentMean?.id; + + return this.paymentMeans?.find((paymentMean) => paymentMean.id === cartPaymentMeanId); + }, + + extensionHasPermissions() { + return !!Object.keys(this.permissions ?? {}).length; + }, + + extensionHasDomains() { + return (this.domains?.length ?? 0) > 0; + }, + + extensionHasPermissionsOrDomains() { + return this.extensionHasPermissions || this.extensionHasDomains; + }, + + isDiscounted() { + return this.position && this.position.netPrice !== this.position.pseudoPrice; + }, + + isFirstMonthFree() { + return this.position && this.position.firstMonthFree; + }, + + durationIntervalSnippet() { + const duration = this.position?.variant.duration; + + switch (duration) { + case 12: + return this.$t('sw-extension-store.purchase-confirmation.checkout.order.perYear'); + case 1: + return this.$t('sw-extension-store.purchase-confirmation.checkout.order.perMonth'); + default: + return null; + } + }, + + netPrice() { + let price = 0; + + if (!this.isFirstMonthFree) { + price = this.position?.netPrice ?? 0; + } + + return this.formatCurrency(price); + }, + + netPriceIntervalSnippet() { + if (this.isFirstMonthFree) { + return this.$t('sw-extension-store.purchase-confirmation.checkout.order.firstMonth'); + } + + return this.durationIntervalSnippet; + }, + + discountedPriceIntervalSnippet() { + let snippet = ''; + + const duration = this.position?.variant.duration; + + if (duration === 12) { + snippet += this.$t('sw-extension-store.purchase-confirmation.checkout.order.forFirstYear'); + } else { + const discountAppliesForMonths = this.position?.discountAppliesForMonths; + + snippet += this.$t('sw-extension-store.purchase-confirmation.checkout.order.forMonths', { + months: discountAppliesForMonths, + }); + } + + if (this.isFirstMonthFree) { + const price = this.formatCurrency(this.position?.netPrice ?? 0); + + if (duration === 12) { + snippet = this.$t('sw-extension-store.purchase-confirmation.checkout.order.followingPerYear', { + price: price, + }) + ' (' + snippet + ')'; + } else { + snippet = this.$t('sw-extension-store.purchase-confirmation.checkout.order.followingPerMonth', { + price: price, + }) + ' ' + snippet; + } + } + + return snippet; + }, + + pseudoPrice() { + return this.formatCurrency(this.position?.pseudoPrice ?? 0); + }, + + userCanBuyFromStore() { + return Shopware.Store.get('shopwareExtensions').userInfo !== null; + }, + + hasPaymentMethodError() { + // A demo shop hides its payment details, so it must not block on them either. + if (this.isDemoShop) { + return false; + } + + return (this.paymentMeans || []).length <= 0 && + this.cart && this.cart.payment && this.cart.payment.paymentMeanRequired; + }, + + canConfirmPurchase() { + return this.userCanBuyFromStore + && this.tocAccepted + && (!this.extensionHasPermissionsOrDomains || this.permissionsAccepted) + && !this.hasPaymentMethodError; + }, + + isCompatible() { + return extensionStorePurchaseConfirmationStore().isCompatible; + }, + + installLabel() { + return this.isTestLicense + ? this.$t('sw-extension-store.purchase-confirmation.checkout.demo.install.label') + : this.$t('sw-extension-store.purchase-confirmation.checkout.install.label'); + }, + + submitLabel() { + const scope = this.isTestLicense ? 'demo.order' : 'order'; + const key = this.installAfterPurchase ? 'submit-and-install' : 'submit'; + + return this.$t(`sw-extension-store.purchase-confirmation.checkout.${scope}.${key}`); + }, + + loadingTitle() { + const step = extensionStorePurchaseConfirmationStore().checkoutStep; + if (step === null) { + return undefined; + } + + return this.$t(`sw-extension-store.purchase-confirmation.checkout.loading.title.${step}`); + }, + + loadingDescription() { + const step = extensionStorePurchaseConfirmationStore().checkoutStep; + if (step === null) { + return undefined; + } + + return this.$t('sw-extension-store.purchase-confirmation.checkout.loading.description'); + }, + }, + + methods: { + formatCurrency(price: number) { + return Utils.format.currency(price, 'EUR', 2); + }, + + onTocAcceptedChange(value: boolean) { + this.$emit('update:toc-accepted', value); + }, + + onPermissionsAcceptedChange(value: boolean) { + this.$emit('update:permissions-accepted', value); + }, + + showPermissions() { + this.$emit('update:modal-view', 'permissions'); + }, + + onInstallAfterPurchaseChange(value: boolean) { + this.$emit('update:install-after-purchase', value); + }, + + closeModal() { + extensionStorePurchaseConfirmationStore().closeModal(); + }, + + cancelPurchase() { + extensionStorePurchaseConfirmationStore().cancel(); + }, + + async confirmPurchase() { + await extensionStorePurchaseConfirmationStore().confirm(); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/sw-extension-store-purchase-confirmation-checkout.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/sw-extension-store-purchase-confirmation-checkout.html.twig new file mode 100644 index 00000000..71589837 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/sw-extension-store-purchase-confirmation-checkout.html.twig @@ -0,0 +1,240 @@ +{% block sw_extension_store_purchase_confirmation_checkout %} +
    + + {% block sw_extension_store_purchase_confirmation_checkout_loader %} +
    + + + +
    + + {{ loadingTitle }} + + + {{ loadingDescription }} + +
    +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_success %} + + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_failed %} + + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_checkout_default %} + + {% endblock %} + +
    +{% endblock %} + +{% block sw_extension_store_purchase_confirmation_footer %} + +{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/sw-extension-store-purchase-confirmation-checkout.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/sw-extension-store-purchase-confirmation-checkout.scss new file mode 100644 index 00000000..4024fd6d --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-checkout/sw-extension-store-purchase-confirmation-checkout.scss @@ -0,0 +1,204 @@ +.sw-extension-store-purchase-confirmation__checkout { + --checkout-padding: 24px; + + display: flex; + flex-direction: column; + gap: 24px; + padding: var(--checkout-padding); + position: relative; + + &-loading { + .mt-loader { + transition: opacity ease-in 200ms; + background-color: rgba(255, 255, 255, 0.65); + backdrop-filter: blur(5px); + opacity: 1; + z-index: 10; + + .mt-loader__container { + top: calc(50% - 50px); + } + } + + &-text { + z-index: 11; + position: absolute; + display: grid; + grid-auto-columns: auto; + text-align: center; + top: 50%; + width: calc(100% - (var(--checkout-padding) * 2)); + + &-title { + color: var(--color-text-primary-default); + font-size: var(--font-size-xl); + line-height: var(--font-line-height-xl); + font-weight: var(--font-weight-bold); + } + + &-description { + color: var(--color-text-secondary-default); + font-size: var(--font-size-s); + line-height: var(--font-line-height-s); + font-weight: var(--font-weight-medium); + } + } + } + + &-success, + &-failed { + display: flex; + flex-direction: column; + gap: 24px; + + > .mt-banner, + > .mt-empty-state { + margin: 0; + max-width: none; + width: 100%; + } + } + + &-install { + display: flex; + flex-direction: column; + gap: var(--scale-size-4); + + &-title { + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + font-weight: var(--font-weight-semibold); + } + + &-switch { + display: flex; + padding: var(--scale-size-12) var(--scale-size-24); + align-items: center; + gap: var(--scale-size-8); + align-self: stretch; + border-radius: var(--border-radius-card); + border: 1px solid var(--color-border-secondary-default); + + &.is--disabled { + background-color: var(--color-background-secondary-default); + } + + .mt-switch { + margin: 0; + + &__switch { + min-width: var(--scale-size-24); + } + + &__label { + padding-left: var(--scale-size-8); + } + } + } + + &-hint { + display: flex; + align-items: center; + gap: var(--scale-size-6); + font-size: var(--font-size-2xs); + line-height: var(--font-line-height-xs); + color: var(--color-orange-700); + + .mt-icon { + color: var(--color-icon-attention-default); + } + } + } + + &-toc { + display: flex; + flex-direction: column; + gap: var(--scale-size-4); + + &-title { + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + font-weight: var(--font-weight-semibold); + } + + &-checkboxes { + color: var(--color-text-secondary-default); + display: flex; + flex-direction: column; + gap: 12px; + + &-item { + align-items: center; + display: flex; + gap: 4px; + + .mt-link { + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .mt-field { + .mt-field__label { + color: var(--color-text-secondary-default); + margin-left: 8px; + } + } + } + } + } + + &-demo-hint { + .mt-banner { + margin: 0; + } + } + + &-order { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 20px; + + &-summary { + &-net-price { + font-size: var(--font-size-m); + line-height: var(--font-line-height-m); + + &-value { + font-weight: var(--font-weight-bold); + } + } + + &-discounted-price, + &-pseudo-price { + color: var(--color-text-secondary-default); + font-size: var(--font-size-s); + line-height: var(--font-line-height-s); + } + } + } + + &-notes { + display: flex; + flex-direction: column; + gap: 4px; + + small { + color: var(--color-text-secondary-default); + font-size: var(--font-size-2xs); + line-height: var(--font-line-height-2xs); + } + } +} + +.sw-extension-store-purchase-confirmation__footer { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + justify-content: flex-end; + padding: var(--scale-size-24); +} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/index.ts b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/index.ts new file mode 100644 index 00000000..ea39318c --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/index.ts @@ -0,0 +1,85 @@ +import extensionStorePurchaseConfirmationStore from '../../store/extension-store-purchase-confirmation.store'; +import template from './sw-extension-store-purchase-confirmation-modal.html.twig'; +import './sw-extension-store-purchase-confirmation-modal.scss'; + +type ModalView = 'checkout' | 'permissions'; + +/** + * @private + */ +export default Shopware.Component.wrapComponentConfig({ + template, + + inject: ['extensionStorePreferencesService'], + + data() { + return { + view: 'checkout' as ModalView, + tocAccepted: false, + permissionsAccepted: false, + }; + }, + + computed: { + isOpen() { + return extensionStorePurchaseConfirmationStore().isOpen; + }, + + isLoading() { + return extensionStorePurchaseConfirmationStore().isLoading; + }, + + cart() { + return extensionStorePurchaseConfirmationStore().cartData; + }, + + paymentMeans() { + return extensionStorePurchaseConfirmationStore().paymentMeansData; + }, + + // Drives the billing and payment details, which a demo shop never shows for any booking. + isDemoShop() { + return this.cart?.bookingShop?.kind === 'demo'; + }, + + isFreeExtension() { + return this.cart?.positions?.[0]?.variant?.name === 'free'; + }, + + // Drives the wording and the pricing. A demo shop books a free extension as a regular free + // order, so only paid extensions become a test license. + isTestLicense() { + return this.isDemoShop && !this.isFreeExtension; + }, + + installAfterPurchase() { + return extensionStorePurchaseConfirmationStore().isCompatible && this.extensionStorePreferencesService.state.installAfterPurchase; + }, + }, + + watch: { + isOpen(value: boolean) { + if (!value) { + this.resetModal(); + } + }, + }, + + methods: { + resetModal() { + this.view = 'checkout'; + this.tocAccepted = false; + this.permissionsAccepted = false; + }, + + onModalChange(value: boolean) { + if (!value) { + extensionStorePurchaseConfirmationStore().cancel(); + } + }, + + updateInstallAfterPurchase(value: boolean) { + this.extensionStorePreferencesService.update({ installAfterPurchase: value }); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/sw-extension-store-purchase-confirmation-modal.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/sw-extension-store-purchase-confirmation-modal.html.twig new file mode 100644 index 00000000..e9f47bc5 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/sw-extension-store-purchase-confirmation-modal.html.twig @@ -0,0 +1,43 @@ +{% block sw_extension_store_purchase_confirmation_modal %} + + + + + + + +{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/sw-extension-store-purchase-confirmation-modal.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/sw-extension-store-purchase-confirmation-modal.scss new file mode 100644 index 00000000..7455d666 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-modal/sw-extension-store-purchase-confirmation-modal.scss @@ -0,0 +1,207 @@ +.mt-modal__footer:has(.sw-extension-store-purchase-confirmation-modal__footer) { + display: none; +} + +.sw-extension-store-purchase-confirmation-modal { + &__content { + display: flex; + flex-direction: column; + } + + &__cart-info { + background-color: #f5f5f5; + border-bottom: 1px solid #d1d5db; + padding: 16px; + max-height: 400px; + overflow-y: auto; + + pre { + margin: 0; + font-size: 12px; + white-space: pre-wrap; + word-wrap: break-word; + } + } + + &__checkout { + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; + + &-overview { + border: 1px solid var(--color-border-secondary-default); + border-radius: var(--border-radius-card); + display: flex; + flex-direction: column; + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + overflow: hidden; + + &-details { + align-items: center; + background: var(--color-background-secondary-default); + display: flex; + gap: 12px; + padding: 24px; + + &-icon { + border-radius: var(--border-radius-card); + height: 56px; + overflow: hidden; + width: 56px; + + &::after { + display: none; + } + } + + &-info { + &-name { + font-size: var(--font-size-s); + font-weight: var(--font-weight-bold); + line-height: var(--font-line-height-s); + } + + &-producer-name { + color: var(--color-text-secondary-default); + + a.mt-link { + color: var(--color-text-primary-default); + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + } + } + + &-info { + display: flex; + flex-direction: column; + padding: 8px 24px; + + &-row { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + flex-direction: column; + padding: 12px 0; + + &:first-child { + border-top: none; + } + + @media screen and (min-width: 768px) { + flex-direction: row; + } + + &-label { + color: var(--color-text-secondary-default); + + @media screen and (min-width: 768px) { + width: 180px; + } + } + + &-value { + a.mt-link { + color: var(--color-text-primary-default); + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &-action { + margin-left: auto; + } + } + } + } + + &-toc { + display: flex; + flex-direction: column; + gap: 12px; + + &-title { + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + font-weight: var(--font-weight-semibold); + } + + &-checkboxes { + color: var(--color-text-secondary-default); + display: flex; + flex-direction: column; + gap: 12px; + + &-item { + align-items: center; + display: flex; + gap: 4px; + + a.mt-link { + font-weight: var(--font-weight-medium); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .mt-field { + .mt-field__label { + color: var(--color-text-secondary-default); + margin-left: 8px; + } + } + } + } + } + + &-order { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 20px; + + &-summary { + &-net-price { + font-size: var(--font-size-m); + line-height: var(--font-line-height-m); + + &-value { + font-weight: var(--font-weight-bold); + } + } + + &-discounted-price, + &-pseudo-price { + color: var(--color-text-secondary-default); + font-size: var(--font-size-s); + line-height: var(--font-line-height-s); + } + } + } + + &-notes { + display: flex; + flex-direction: column; + gap: 4px; + + small { + color: var(--color-text-secondary-default); + font-size: var(--font-size-2xs); + line-height: var(--font-line-height-2xs); + } + } + } +} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/index.ts b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/index.ts new file mode 100644 index 00000000..78f3e321 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/index.ts @@ -0,0 +1,109 @@ +import template from './sw-extension-store-purchase-confirmation-permissions.html.twig'; +import './sw-extension-store-purchase-confirmation-permissions.scss'; + +import type { ExtensionStoreBasket } from '../../types/extension-store-basket.types'; + +/** + * @private + */ +export default Shopware.Component.wrapComponentConfig({ + template, + + props: { + cart: { + type: Object as PropType, + required: true, + }, + }, + + emits: [ + 'update:modal-view', + ], + + computed: { + position() { + return this.cart?.positions?.[0]; + }, + + extension() { + return this.position?.extension; + }, + + operations() { + return [ + { + label: this.$tc('sw-extension-store.component.sw-extension-permissions-details-modal.operationRead'), + operation: 'read', + }, + { + label: this.$tc('sw-extension-store.component.sw-extension-permissions-details-modal.operationUpdate'), + operation: 'update', + }, + { + label: this.$tc('sw-extension-store.component.sw-extension-permissions-details-modal.operationCreate'), + operation: 'create', + }, + { + label: this.$tc('sw-extension-store.component.sw-extension-permissions-details-modal.operationDelete'), + operation: 'delete', + }, + ]; + }, + + permissions() { + const extensionPermissions = this.extension.permissions; + if (!extensionPermissions || Object.keys(extensionPermissions).length === 0) { + return null; + } + + return Object.fromEntries( + Object.entries(this.extension.permissions).map( + ([ + category, + permissions, + ]) => { + const grouped = permissions.reduce>((acc, permission) => { + const entity = permission.entity; + + if (entity === 'additional_privileges') { + acc[permission.operation] = []; + + return acc; + } + + acc[entity] = (acc[entity] || []).concat(permission.operation); + + return acc; + }, {}); + return [ + category, + grouped, + ]; + }, + ), + ); + }, + + domains() { + return this.extension.domains.filter((domain) => domain !== null); + }, + }, + + methods: { + categoryLabel(category: string) { + const translation = `entityCategories.${category}.title`; + + return this.$te(translation) ? this.$tc(translation) : category; + }, + + entityLabel(category: string, entity: string) { + const translation = `entityCategories.${category}.entities.${entity}`; + + return this.$te(translation) ? this.$tc(translation) : entity; + }, + + showCheckout() { + this.$emit('update:modal-view', 'checkout'); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/sw-extension-store-purchase-confirmation-permissions.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/sw-extension-store-purchase-confirmation-permissions.html.twig new file mode 100644 index 00000000..6137e33c --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/sw-extension-store-purchase-confirmation-permissions.html.twig @@ -0,0 +1,159 @@ +{% block sw_extension_store_purchase_confirmation_permissions %} +
    + {% block sw_extension_store_purchase_confirmation_permissions_intro %} +
    +
    +
    + +
    +
    + + {{ extension.label }} + + + {{ $t('sw-extension-store.purchase-confirmation.permissions.intro.label') }} + +
    +
    +
    +

    + {{ $t('sw-extension-store.purchase-confirmation.permissions.intro.descriptionPermissions', { + extensionLabel: extension.label, + }) }} +

    +
    +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_permissions_table %} + + {% block sw_extension_store_purchase_confirmation_permissions_table_head %} + + {% block sw_extension_store_purchase_confirmation_permissions_table_head_row %} + + + + {% endblock %} + + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_permissions_table_body %} + + {% block sw_extension_store_purchase_confirmation_permissions_table_permissions %} + + {% endblock %} + + {% endblock %} +
    + + {{ operation.label }} +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_permissions_domains %} +
    + {% block sw_extension_store_purchase_confirmation_permissions_domains_label %} +
    + {{ $t('sw-extension-store.purchase-confirmation.permissions.domains.label') }} +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_permissions_domains_description %} +
    +

    {{ $t('sw-extension-store.purchase-confirmation.permissions.domains.description') }}

    +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_permissions_domains_list %} +
    +
      + {% block sw_extension_domains_modal_list_entry %} +
    • + {{ domain }} +
    • + {% endblock %} +
    +
    + {% endblock %} +
    + {% endblock %} + + {% block sw_extension_store_purchase_confirmation_permissions_footer %} + + {% endblock %} +
    +{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/sw-extension-store-purchase-confirmation-permissions.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/sw-extension-store-purchase-confirmation-permissions.scss new file mode 100644 index 00000000..938bd118 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-purchase-confirmation-permissions/sw-extension-store-purchase-confirmation-permissions.scss @@ -0,0 +1,189 @@ +.sw-extension-store-purchase-confirmation-permissions { + display: flex; + flex-direction: column; + + &__intro { + display: flex; + flex-direction: column; + gap: 24px; + padding: var(--scale-size-24); + + &-extension { + align-items: center; + display: flex; + gap: 12px; + + &-icon { + background-color: var(--color-background-brand-default); + border-radius: var(--border-radius-card); + color: var(--color-interaction-primary-pressed); + display: flex; + align-items: center; + justify-content: center; + height: 56px; + overflow: hidden; + width: 56px; + } + + &-title { + display: flex; + flex-direction: column; + + &-name { + color: var(--color-text-primary-default); + font-size: var(--font-size-m); + font-weight: var(--font-weight-bold); + line-height: var(--font-line-height-m); + } + + &-label { + color: var(--color-text-secondary-default); + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + } + } + } + + &-description { + p { + color: var(--color-text-secondary-default); + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + } + } + } + + &__table { + background-color: var(--color-elevation-surface-sunken); + border-collapse: collapse; + border-spacing: unset; + border-top: 1px solid var(--color-border-secondary-default); + font-size: var(--font-size-xs); + + .sw-extension-store-purchase-confirmation-permissions__operations { + border-bottom: 1px solid var(--color-border-secondary-default); + display: block; + top: 0; + position: sticky; + background: #fff; + + tr { + &.sw-extension-store-purchase-confirmation-permissions__grid-columns { + padding: var(--scale-size-18) 24px var(--scale-size-18) 44px; + } + + td { + &.sw-extension-store-purchase-confirmation-permissions__operation-header { + color: var(--color-text-secondary-default); + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + text-align: center; + } + } + } + } + + tbody { + tr { + &:last-child { + padding-bottom: var(--scale-size-22); + } + } + } + + tr { + padding: 0 var(--scale-size-24); + + &.sw-extension-store-purchase-confirmation-permissions__category { + display: grid; + font-weight: var(--font-weight-bold); + background: var(--color-elevation-surface-sunken); + + &:not(:first-child) { + margin-top: var(--scale-size-22); + + td { + border-top: 1px solid var(--color-border-secondary-default); + } + } + + td { + padding: var(--scale-size-16) 0 var(--scale-size-6) 0; + } + } + + &.sw-extension-store-purchase-confirmation-permissions__grid-columns { + display: grid; + grid-template-columns: auto repeat(4, 80px); + padding: var(--scale-size-6) 24px var(--scale-size-6) 44px; + } + + td { + font-size: var(--font-size-s); + line-height: var(--font-line-height-s); + + &.sw-extension-store-purchase-confirmation-permissions__operation { + text-align: center; + + &.sw-extension-store-purchase-confirmation-permissions__operation-positive { + color: var(--color-icon-positive-default); + } + + &.sw-extension-store-purchase-confirmation-permissions__operation-negative { + color: var(--color-icon-secondary-default); + } + } + } + } + } + + &__domains { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + flex-direction: column; + gap: 12px; + padding: var(--scale-size-24); + + &-label { + color: var(--color-text-primary-default); + font-size: var(--font-size-s); + font-weight: var(--font-weight-bold); + line-height: var(--font-line-height-s); + } + + &-description { + p { + color: var(--color-text-secondary-default); + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + } + } + + &-list { + display: flex; + flex-direction: column; + gap: 12px; + + &-items { + list-style: none; + + li { + font-size: var(--font-size-xs); + line-height: var(--font-line-height-xs); + padding: var(--scale-size-10) 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-border-secondary-default); + } + } + } + } + } + + &__footer { + border-top: 1px solid var(--color-border-secondary-default); + display: flex; + justify-content: center; + padding: var(--scale-size-24); + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/index.js deleted file mode 100644 index df32f100..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/index.js +++ /dev/null @@ -1,189 +0,0 @@ -import template from './sw-extension-store-slider.html.twig'; -import './sw-extension-store-slider.scss'; - -const maxSlides = 3; - -export default { - template, - - props: { - images: { - type: Array, - required: true, - }, - infinite: { - type: Boolean, - required: false, - default: false, - }, - slideCount: { - type: Number, - required: false, - default: 2, - }, - large: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - activeImgIndex: 0, - lastActiveImgIndex: null, - isDirectionRight: null, - }; - }, - - computed: { - cardClasses() { - return { - 'sw-card--large': this.large, - }; - }, - - lastActive() { - return this.activeImgIndex + this.usedSlideCount - 1; - }, - - isDisabledNext() { - return this.isInfinite ? false : this.lastActive === this.images.length - 1; - }, - - isDisabledPrevious() { - return this.isInfinite ? false : this.activeImgIndex === 0; - }, - - sliderHasOneImageMoreThanTheSlideCount() { - return this.images.length === this.usedSlideCount + 1; - }, - - isInfinite() { - return this.sliderHasOneImageMoreThanTheSlideCount ? false : this.infinite; - }, - - usedSlideCount() { - if (this.slideCount > maxSlides) { - return maxSlides; - } - - return this.slideCount < 1 ? 1 : this.slideCount; - }, - }, - - methods: { - getActiveStyle(key) { - if (!this.isActive(key)) { - return {}; - } - - const move = 100 / this.usedSlideCount; - - // handle start position after component creation - if (this.isDirectionRight === null) { - return { - left: `${key * move}%`, - }; - } - - return this.moveActiveImage(key, move); - }, - - moveActiveImage(key, move) { - // guarantee that first active image is always left - if (key === this.activeImgIndex) { - return { - left: '0%', - }; - } - - if (!this.$refs.movingImageWrapper) { - return {}; - } - - const image = this.$refs.movingImageWrapper.querySelector(`[data-key="${key}"]`); - const position = parseInt(image.style.left, 10); - - // handle images that were not active before - if (Number.isNaN(position)) { - return { - left: `${100 - move}%`, - }; - } - - return { - left: this.isDirectionRight ? `${position - move}%` : `${position + move}%`, - }; - }, - - next() { - this.isDirectionRight = true; - this.changeSlide(+1); - }, - - previous() { - this.isDirectionRight = false; - this.changeSlide(-1); - }, - - changeSlide(value) { - this.lastActiveImgIndex = this.activeImgIndex; - - if (this.isInfinite) { - if (this.activeImgIndex === 0 && !this.isDirectionRight) { - this.activeImgIndex = this.images.length - 1; - return; - } - - if (this.activeImgIndex === this.images.length - 1 && this.isDirectionRight) { - this.activeImgIndex = 0; - return; - } - } - - this.activeImgIndex += value; - }, - - isActive(index) { - let isActive = false; - - for (let i = 0; i < this.usedSlideCount; i += 1) { - if (isActive) { - return true; - } - - isActive = this.activeImgIndex + i === index; - } - - return isActive - || (this.lastActive > this.images.length - 1 - && index <= (this.lastActive) - this.images.length); - }, - - isNext(index) { - const next = this.activeImgIndex + this.usedSlideCount; - - return next === index - || (next > this.images.length - 1 - && (next) - this.images.length === index); - }, - - slideClasses(index) { - if (this.sliderHasOneImageMoreThanTheSlideCount) { - return { - 'is--previous': index === 0 && !this.isActive(index), - 'is--next': this.images.length - 1 === index && !this.isActive(index), - 'is--active': this.isActive(index), - }; - } - - return { - 'is--previous': this.activeImgIndex - 1 === index - || (this.activeImgIndex === 0 && index === this.images.length - 1), - 'is--active': this.isActive(index), - 'is--next': this.isNext(index), - }; - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.html.twig deleted file mode 100644 index 09aa483d..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.html.twig +++ /dev/null @@ -1,52 +0,0 @@ -{% block sw_extension_store_slider %} -
    - {% block sw_extension_store_slider_slides %} -
    - {% block sw_extension_store_slider_loader %} -
    - -
    - {% endblock %} - - -
    - {% endblock %} - - {% block sw_extension_store_slider_navigation %} -
    - {% block sw_extension_store_slider_navigation_inner %} - - - - {% endblock %} -
    - {% endblock %} -
    -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.scss deleted file mode 100644 index f658f921..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.scss +++ /dev/null @@ -1,140 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-slider { - &.mt-card--large { - padding: 0 30px; - - .sw-extension-store-slider__navigation { - width: 100%; - left: 0; - } - - .sw-extension-store-slider__slides { - height: 31vw; - // the height results from the standard use case: two images per slide with 4:3 aspect ratio - max-height: 461px; - } - } - - @media screen and (max-width: 935px) { - padding: 0 30px; - } - - .sw-extension-store-slider__navigation { - position: absolute; - display: grid; - grid-template-columns: 1fr 1fr; - align-content: center; - width: calc(100% + 60px); - left: -30px; - top: 50%; - transform: translate(0, -50%); - - @media screen and (max-width: 935px) { - width: 100%; - left: 0; - } - - .sw-extension-store-slider__btn-back, - .sw-extension-store-slider__btn-next { - border: 0; - padding: 10px 5px; - background: none; - color: var(--color-icon-secondary-default); - outline: none; - cursor: pointer; - - &:disabled { - color: var(--color-icon-primary-disabled); - cursor: not-allowed; - } - } - - .sw-extension-store-slider__btn-back { - justify-self: start; - } - - .sw-extension-store-slider__btn-next { - justify-self: end; - } - } - - .sw-extension-store-slider__slides { - height: 35vw; - // the height results from the standard use case: two images per slide with 4:3 aspect ratio - max-height: 332px; - display: flex; - overflow: hidden; - border-radius: $border-radius-lg; - position: relative; - - .sw-extension-store-slider__slide-item { - background: var(--color-elevation-surface-raised); - transition: all 0.3s ease-in-out; - height: 100%; - width: 100%; - overflow: hidden; - position: absolute; - display: none; - - &.is--previous { - display: block; - left: -100%; - } - - &.is--next { - display: block; - left: 100%; - z-index: -1; - } - - &.is--active { - display: block; - z-index: 1; - } - - img { - width: 100%; - height: 100%; - object-fit: contain; - } - } - - .sw-extension-store-slider__loader { - z-index: 2; - height: 100%; - width: 100%; - - .sw-loader { - background: none; - } - } - } - - &.sw-extension-store-slider__slides-count-2, - &.sw-extension-store-slider__slides-count-3 { - padding: 0 (30px - 17px) 0 30px; - } - - &.sw-extension-store-slider__slides-count-2 { - .sw-extension-store-slider__slide-item { - width: calc(50% - 17px); - margin-right: 17px; - - &.is--previous { - left: -50%; - } - } - } - - &.sw-extension-store-slider__slides-count-3 { - .sw-extension-store-slider__slide-item { - width: calc((100% / 3) - 17px); - margin-right: 17px; - - &.is--previous { - left: calc(-100% / 3); - } - } - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.spec.js deleted file mode 100644 index 8e0eb3d7..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-slider/sw-extension-store-slider.spec.js +++ /dev/null @@ -1,438 +0,0 @@ - -import { mount } from '@vue/test-utils'; - -Shopware.Component.register( - 'sw-extension-store-slider', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-slider'), -); - -async function createWrapper() { - return mount(await Shopware.Component.build('sw-extension-store-slider'), { - props: { - images: [], - }, - global: { - stubs: { - 'sw-loader': true, - }, - provide: {}, - }, - }); -} - -describe('src/module/sw-extension-store/component/sw-extension-store-slider', () => { - let wrapper; - - beforeEach(async () => { - wrapper = await createWrapper(); - }); - - it('should be a Vue.js component', async () => { - expect(wrapper.vm).toBeTruthy(); - }); - - // TODO: a fallback is not implemented now - // it('should show a placeholder image if no images are provided', async () => { - // }); - - it('should show a single image', async () => { - const images = ['https://via.placeholder.com/800x400']; - await wrapper.setProps({ - images: images, - }); - - const singleImage = wrapper.findAll('img'); - expect(singleImage).toHaveLength(1); - expect(singleImage.at(0).attributes()).toHaveProperty('src'); - expect(singleImage.at(0).attributes().src).toEqual(images[0]); - }); - - [ - { slideCount: 2, imagesLength: 4 }, - { slideCount: 1, imagesLength: 2 }, - { slideCount: 3, imagesLength: 6 }, - ].forEach((sample) => { - it(`should show the navigation only when images.length (${sample.imagesLength}) is higher than slideCount (${sample.slideCount})`, async () => { - await wrapper.setProps({ - images: Array(sample.imagesLength).map(() => 'https://via.placeholder.com/800x400'), - slideCount: sample.slideCount, - }); - - const sliderNavigation = wrapper.find('.sw-extension-store-slider__navigation'); - expect(sliderNavigation.exists()).toBe(true); - expect(sliderNavigation.isVisible()).toBe(true); - }); - }); - - [ - { slideCount: 1, imagesLength: 1 }, - { slideCount: 2, imagesLength: 1 }, - { slideCount: 2, imagesLength: 2 }, - { slideCount: 1, imagesLength: 1 }, - { slideCount: 3, imagesLength: 3 }, - ].forEach((sample) => { - it(`should not show the navigation only when images.length (${sample.imagesLength}) is lower or equal than slideCount (${sample.slideCount})`, async () => { - await wrapper.setProps({ - images: Array(sample.imagesLength).map(() => 'https://via.placeholder.com/800x400'), - slideCount: sample.slideCount, - }); - - const sliderNavigation = wrapper.find('.sw-extension-store-slider__navigation'); - expect(sliderNavigation.exists()).toBe(false); - }); - }); - - [1, 2, 3].forEach((slideCount) => { - it(`should show the number of active images of slide count (${slideCount})`, async () => { - const images = [ - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: slideCount, - }); - - const activeItems = wrapper.findAll('.sw-extension-store-slider__slide-item.is--active'); - expect(activeItems).toHaveLength(slideCount); - }); - }); - - it('should not set a higher slide count than maxSlides(3)', async () => { - const images = [ - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - 'https://via.placeholder.com/800x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 5, - }); - - const activeItems = wrapper.findAll('.sw-extension-store-slider__slide-item.is--active'); - expect(activeItems).toHaveLength(3); - }); - - it('should show multiple images at the same time', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - 'https://via.placeholder.com/400x400', - 'https://via.placeholder.com/500x400', - 'https://via.placeholder.com/600x400', - 'https://via.placeholder.com/700x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 3, - }); - - const activeItems = wrapper.findAll('.sw-extension-store-slider__slide-item.is--active'); - expect(activeItems).toHaveLength(3); - - // check if image sources are set correctly - activeItems.forEach(activeItem => { - const dataKey = activeItem.attributes()['data-key']; - expect(activeItem.find('img').attributes().src).toEqual(images[dataKey]); - }); - }); - - it('should navigate forward', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - 'https://via.placeholder.com/400x400', - 'https://via.placeholder.com/500x400', - 'https://via.placeholder.com/600x400', - 'https://via.placeholder.com/700x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 1, - }); - - const buttonNext = wrapper.find('.sw-extension-store-slider__btn-next'); - - const firstItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="0"]'); - const secondItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="1"]'); - const thirdItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="2"]'); - - // first item should be the active item by default - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // go to second item - await buttonNext.trigger('click'); - - // second item should be the active item now - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // go to third item - await buttonNext.trigger('click'); - - // third item should be the active item now - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).toContain('is--active'); - }); - it('should navigate backward', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - 'https://via.placeholder.com/400x400', - 'https://via.placeholder.com/500x400', - 'https://via.placeholder.com/600x400', - 'https://via.placeholder.com/700x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 1, - }); - - const buttonNext = wrapper.find('.sw-extension-store-slider__btn-next'); - const buttonBack = wrapper.find('.sw-extension-store-slider__btn-back'); - - const firstItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="0"]'); - const secondItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="1"]'); - const thirdItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="2"]'); - - // first item should be the active item by default - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // go to third item - await buttonNext.trigger('click'); - await buttonNext.trigger('click'); - - // third item should be the active item now - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).toContain('is--active'); - - // go back to second item - await buttonBack.trigger('click'); - - // second item should be the active item now - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // go back to first item - await buttonBack.trigger('click'); - - // second item should be the active item now - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - }); - - it('should not navigate back when user see first image and infinite is off', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 1, - }); - - const buttonBack = wrapper.find('.sw-extension-store-slider__btn-back'); - - const firstItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="0"]'); - const secondItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="1"]'); - const thirdItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="2"]'); - - // first item should be the active item by default - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // back button should be disabled - expect(buttonBack.attributes().disabled).toBeDefined(); - - // a click on this should not trigger anything - await buttonBack.trigger('click'); - - // first item should stay the active item - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - }); - - it('should navigate back when user see first image and infinite is on', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 1, - infinite: true, - }); - - const buttonBack = wrapper.find('.sw-extension-store-slider__btn-back'); - - const firstItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="0"]'); - const secondItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="1"]'); - const thirdItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="2"]'); - - // first item should be the active item by default - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // back button should not be disabled - expect(buttonBack.attributes().disabled).toBeUndefined(); - - // go to image before - await buttonBack.trigger('click'); - - // third item should now be the active item - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).toContain('is--active'); - }); - - it('should not navigate forward when user see last image and infinite is off', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 1, - }); - - const buttonNext = wrapper.find('.sw-extension-store-slider__btn-next'); - - const firstItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="0"]'); - const secondItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="1"]'); - const thirdItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="2"]'); - - // first item should be the active item by default - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // go to third image - await buttonNext.trigger('click'); - await buttonNext.trigger('click'); - - // third item should be the active item - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).toContain('is--active'); - - // next button should be disabled - expect(buttonNext.attributes().disabled).toBeDefined(); - - // a click on this should not trigger anything - await buttonNext.trigger('click'); - - // third item should stay the active item - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).toContain('is--active'); - }); - - it('should navigate forward when user see last image and infinite is on', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - ]; - - await wrapper.setProps({ - images: images, - slideCount: 1, - infinite: true, - }); - - const buttonNext = wrapper.find('.sw-extension-store-slider__btn-next'); - - const firstItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="0"]'); - const secondItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="1"]'); - const thirdItem = wrapper.find('.sw-extension-store-slider__slide-item[data-key="2"]'); - - // first item should be the active item by default - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - - // go to third image - await buttonNext.trigger('click'); - await buttonNext.trigger('click'); - - // third item should be the active item - expect(firstItem.classes()).not.toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).toContain('is--active'); - - // next button should not be disabled - expect(buttonNext.attributes().disabled).toBeUndefined(); - - // a click on this should trigger next image - await buttonNext.trigger('click'); - - // first item should now be the active item - expect(firstItem.classes()).toContain('is--active'); - expect(secondItem.classes()).not.toContain('is--active'); - expect(thirdItem.classes()).not.toContain('is--active'); - }); - - it('should show a small slider as default', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - ]; - - await wrapper.setProps({ - images: images, - }); - - expect(wrapper.classes()).not.toContain('sw-card--large'); - }); - - it('should show a large slider when using property', async () => { - const images = [ - 'https://via.placeholder.com/100x400', - 'https://via.placeholder.com/200x400', - 'https://via.placeholder.com/300x400', - ]; - - await wrapper.setProps({ - images: images, - large: true, - }); - - expect(wrapper.classes()).toContain('sw-card--large'); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/index.js deleted file mode 100644 index 838ff3a3..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/index.js +++ /dev/null @@ -1,55 +0,0 @@ -import template from './sw-extension-store-statistics-promotion.html.twig'; -import './sw-extension-store-statistics-promotion.scss'; - -const STATISTICS_APP_NAME = 'SwagAnalytics'; - -export default Shopware.Component.wrapComponentConfig({ - template, - - inject: ['extensionStoreDataService'], - - data() { - return { - extension: null, - isAppInstalled: false, - }; - }, - - computed: { - assetFilter() { - return Shopware.Filter.getByName('asset'); - }, - - showBanner() { - // If the app is installed and deactivated, we still want to not show the banner - return !this.isAppInstalled; - }, - - linkToStatisticsAppExists() { - return !!this.extension; - }, - }, - - created() { - this.createdComponent(); - }, - - methods: { - async createdComponent() { - this.isAppInstalled = !!Shopware.Context.app.config.bundles[STATISTICS_APP_NAME]; - - this.extension = await this.extensionStoreDataService.getExtensionByName( - STATISTICS_APP_NAME, - Shopware.Context.api, - ); - }, - - goToStatisticsAppDetailPage() { - if (!this.linkToStatisticsAppExists) { - return; - } - - this.$router.push({ name: 'sw.extension.store.detail', params: { id: this.extension.id } }); - }, - }, -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.html.twig deleted file mode 100644 index cf055056..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.html.twig +++ /dev/null @@ -1,30 +0,0 @@ -
    -
    - - -
    -

    - {{ $t('sw-extension-store-statistics-promotion.app-name') }} -

    - - - {{ $t('sw-extension-store-statistics-promotion.app-description') }} - -
    -
    - - - {{ $t('sw-extension-store-statistics-promotion.go-to-app') }} - -
    diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.scss deleted file mode 100644 index ddf808c3..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.scss +++ /dev/null @@ -1,56 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-statistics-promotion { - container-type: inline-size; - background: linear-gradient(#003075, #0156d0); - padding: 16px 20px; - border-radius: $border-radius-lg; - - h4 { - font-size: 16px; - line-height: 140%; - font-weight: $font-weight-bold; - } - - &__app { - color: var(--color-text-static-default); - float: left; - margin-right: 10px; - display: grid; - grid-template-columns: max-content max-content; - align-items: center; - - &-icon { - height: 64px; - width: 64px; - border-radius: $border-radius-md; - margin-right: 16px; - float: left; - } - - &-info { - float: left; - margin-top: 5px; - - &-name { - color: var(--color-text-static-default); - margin-bottom: 4px; - } - - &-description { - font-size: 14px; - } - } - } - - &__go-to-app { - float: right; - margin-top: 14px; - } - - @container (max-width: 525px) { - &__go-to-app { - float: left; - } - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.spec.js deleted file mode 100644 index 59b5b63e..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-statistics-promotion/sw-extension-store-statistics-promotion.spec.js +++ /dev/null @@ -1,101 +0,0 @@ -import { mount } from '@vue/test-utils'; - -const STATISTICS_APP_NAME = 'SwagAnalytics'; - -Shopware.Component.register( - 'sw-extension-store-statistics-promotion', - () => import('SwagExtensionStore/module/sw-extension-store/component/sw-extension-store-statistics-promotion'), -); - -describe('src/module/sw-extension-store/component/sw-extension-store-statistics-promotion', () => { - const router = {}; - - async function createWrapper(isAppExistingInTheStore = true) { - const app = !isAppExistingInTheStore ? null : { - id: 99999, - label: 'Statistics service app by shopware', - name: STATISTICS_APP_NAME, - }; - - router.push = jest.fn(); - - return mount(await Shopware.Component.build('sw-extension-store-statistics-promotion'), { - global: { - provide: { - extensionStoreDataService: { - getExtensionByName: jest.fn(() => Promise.resolve(app)), - }, - }, - stubs: { - 'sw-extension-icon': true, - }, - mocks: { - $router: router, - }, - }, - }); - } - - function installApp(isActive = true) { - Shopware.Context.app.config.bundles[STATISTICS_APP_NAME] = { - baseUrl: null, - css: [], - js: [], - type: 'app', - active: isActive, - }; - } - - function uninstallApp() { - delete Shopware.Context.app.config.bundles[STATISTICS_APP_NAME]; - } - - beforeEach(() => { - if (!Shopware.Context.app.config.bundles) { - Shopware.Context.app.config.bundles = {}; - } - }); - - afterEach(() => { - uninstallApp(); - }); - - it('shows the banner if the app is not installed', async () => { - const wrapper = await createWrapper(); - - expect(wrapper.find('.sw-extension-store-statistics-promotion').exists()).toBe(true); - }); - - it('does not show the banner if the app is installed', async () => { - installApp(); - const wrapper = await createWrapper(); - - expect(wrapper.find('.sw-extension-store-statistics-promotion').exists()).toBe(false); - }); - - it('does not show the banner if the app is installed but deactivated', async () => { - installApp(false); - const wrapper = await createWrapper(); - - expect(wrapper.find('.sw-extension-store-statistics-promotion').exists()).toBe(false); - }); - - it('disables the button if the app is not found in the store', async () => { - const wrapper = await createWrapper(false); - - expect(wrapper.find('button[disabled]').exists()).toBe(true); - - wrapper.vm.goToStatisticsAppDetailPage(); - expect(router.push).toHaveBeenCalledTimes(0); - }); - - it('enables the button if the app is found in the store', async () => { - const wrapper = await createWrapper(true); - - expect(wrapper.find('button').exists()).toBe(true); - expect(wrapper.find('button[disabled]').exists()).toBe(false); - - wrapper.vm.goToStatisticsAppDetailPage(); - expect(router.push).toHaveBeenCalled(); - }); -}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-type-label/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-type-label/index.js deleted file mode 100644 index d11416d4..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-type-label/index.js +++ /dev/null @@ -1,26 +0,0 @@ -import template from './sw-extension-store-type-label.html.twig'; - -/** - * @private - */ -export default { - template, - - props: { - type: { - type: String, - required: true, - validator(value) { - const validTypes = ['app', 'plugin']; - - return validTypes.includes(value); - }, - }, - }, - - computed: { - isApp() { - return this.type === 'app'; - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-type-label/sw-extension-store-type-label.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-type-label/sw-extension-store-type-label.html.twig deleted file mode 100644 index 2dcf20dc..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-type-label/sw-extension-store-type-label.html.twig +++ /dev/null @@ -1,18 +0,0 @@ -{% block sw_extension_type_label %} -
    - {% block sw_extension_type_label_self_hosted %} - - {{ $t('sw-extension-store.typeLabels.self-hosted') }} - - {% endblock %} - - {% block sw_extension_type_label_cloud %} - - {{ $t('sw-extension-store.typeLabels.cloud') }} - - {% endblock %} -
    -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/index.js b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/index.js deleted file mode 100644 index b3eb4b3b..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/index.js +++ /dev/null @@ -1,56 +0,0 @@ -import template from './sw-extension-store-update-warning.html.twig'; -import './sw-extension-store-update-warning.scss'; - -const { Mixin } = Shopware; - -/** - * @private - */ -export default { - template, - - inject: [ - 'shopwareExtensionService', - 'extensionStoreActionService', - 'cacheApiService', - ], - - mixins: [ - Mixin.getByName('notification'), - ], - - data() { - return { - isUpdating: false, - }; - }, - - computed: {}, - - methods: { - async updateExtension() { - this.isUpdating = true; - - try { - await this.extensionStoreActionService.downloadExtension('SwagExtensionStore'); - await this.shopwareExtensionService.updateExtension('SwagExtensionStore', 'plugin'); - await this.clearCacheAndReloadPage(); - } catch (e) { - this.isUpdating = false; - - Shopware.Utils.debug.error(e); - this.createNotificationError({ - message: this.$t( - 'global.notification.unspecifiedSaveErrorMessage', - ), - }); - } - }, - - clearCacheAndReloadPage() { - return this.cacheApiService.clear().then(() => { - window.location.reload(); - }); - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/sw-extension-store-update-warning.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/sw-extension-store-update-warning.html.twig deleted file mode 100644 index e2ff8408..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/sw-extension-store-update-warning.html.twig +++ /dev/null @@ -1,30 +0,0 @@ -{% block sw_extension_store_update_warning %} - - - - - -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/sw-extension-store-update-warning.scss b/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/sw-extension-store-update-warning.scss deleted file mode 100644 index 3fef4cec..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/component/sw-extension-store-update-warning/sw-extension-store-update-warning.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-update-warning { - &__strong-text { - font-weight: $font-weight-semi-bold; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/index.js b/src/Resources/app/administration/src/module/sw-extension-store/index.js index 8b3eb4ea..e4db9e6c 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/index.js +++ b/src/Resources/app/administration/src/module/sw-extension-store/index.js @@ -1,43 +1,40 @@ -import ExtensionStoreService from './service/extension-store.service'; -import ExtensionStoreDataService from './service/extension-store-data.service'; import ExtensionLicenseService from './service/extension-store-licenses.service'; +import { + ExtensionStoreChannelService, +} from 'SwagExtensionStore/module/sw-extension-store/service/extension-store-channel.service'; +import ExtensionStorePreferencesService from './service/extension-store-preferences.service'; +import extensionStoreContextStore from './store/extension-store-context.store'; Shopware.Component.register('sw-extension-store-index', () => import('./page/sw-extension-store-index')); -Shopware.Component.register('sw-extension-store-listing', () => import('./page/sw-extension-store-listing')); -Shopware.Component.register('sw-extension-store-detail', () => import('./page/sw-extension-store-detail')); -Shopware.Component.register('sw-extension-store-slider', () => import('./component/sw-extension-store-slider')); -Shopware.Component.register('sw-extension-store-listing-filter', () => import('./component/sw-extension-store-listing-filter')); -Shopware.Component.register('sw-extension-buy-modal', () => import('./component/sw-extension-buy-modal')); -Shopware.Component.register('sw-extension-listing-card', () => import('./component/sw-extension-listing-card')); -Shopware.Component.register('sw-extension-store-update-warning', () => import('./component/sw-extension-store-update-warning')); -Shopware.Component.register('sw-extension-label', () => import('./component/sw-extension-store-label')); -Shopware.Component.register('sw-extension-type-label', () => import('./component/sw-extension-store-type-label')); -Shopware.Component.register('sw-extension-store-label-display', () => import('./component/sw-extension-store-label-display')); -Shopware.Component.register('sw-extension-store-error-card', () => import('./component/sw-extension-store-error-card')); -Shopware.Component.register('sw-extension-store-accessibility', () => import('./component/sw-extension-store-accessibility')); -Shopware.Component.register('sw-extension-store-statistics-promotion', () => import('./component/sw-extension-store-statistics-promotion')); +Shopware.Component.register('sw-extension-store-purchase-confirmation-checkout', () => import('./component/sw-extension-store-purchase-confirmation-checkout')); +Shopware.Component.register('sw-extension-store-purchase-confirmation-checkout-overview', () => import('./component/sw-extension-store-purchase-confirmation-checkout-overview')); +Shopware.Component.register('sw-extension-store-purchase-confirmation-modal', () => import('./component/sw-extension-store-purchase-confirmation-modal')); +Shopware.Component.register('sw-extension-store-purchase-confirmation-permissions', () => import('./component/sw-extension-store-purchase-confirmation-permissions')); Shopware.Component.register('sw-extension-store-in-app-purchases-listing-modal', () => import('./component/sw-extension-store-in-app-purchases-listing-modal')); - -Shopware.Application.addServiceProvider('extensionStoreService', () => { - return new ExtensionStoreService( - Shopware.Service('shopwareDiscountCampaignService'), +Shopware.Application.addServiceProvider('extensionStoreChannelService', () => { + return new ExtensionStoreChannelService( + Shopware.Service('extensionStoreActionService'), Shopware.Service('shopwareExtensionService'), + Shopware.Service('extensionStoreLicensesService'), + Shopware.Service('extensionHelperService'), + Shopware.Service('cacheApiService'), + Shopware.Application.view.router, + Shopware.Service('extensionStorePreferencesService'), ); }); -Shopware.Application.addServiceProvider('extensionStoreDataService', () => { - return new ExtensionStoreDataService( +Shopware.Application.addServiceProvider('extensionStoreLicensesService', () => { + return new ExtensionLicenseService( Shopware.Application.getContainer('init').httpClient, Shopware.Service('loginService'), ); }); -Shopware.Application.addServiceProvider('extensionStoreLicensesService', () => { - return new ExtensionLicenseService( - Shopware.Application.getContainer('init').httpClient, - Shopware.Service('loginService'), +Shopware.Application.addServiceProvider('extensionStorePreferencesService', () => { + return new ExtensionStorePreferencesService( + Shopware.Service('userConfigService'), ); }); @@ -48,74 +45,43 @@ Shopware.Module.register('sw-extension-store', { routePrefixPath: 'sw/extension', routes: { store: { - path: 'store', - redirect: { - name: 'sw.extension.store.listing', - }, + path: 'store/:pathMatch(.*)*', + name: 'Store', meta: { privilege: 'system.extension_store', }, component: 'sw-extension-store-index', - children: { - listing: { - path: 'listing', - component: 'sw-extension-store-listing', - redirect: { - name: 'sw.extension.store.listing.app', - }, - meta: { - privilege: 'system.extension_store', - }, - children: { - app: { - path: 'app', - component: 'sw-extension-store-listing', - propsData: { - isTheme: false, - }, - meta: { - privilege: 'system.extension_store', - }, - }, - theme: { - path: 'theme', - component: 'sw-extension-store-listing', - propsData: { - isTheme: true, - }, - meta: { - privilege: 'system.extension_store', - }, - }, - }, - }, - }, }, + // The Shopware Admin requires this exact route name (sw.extension.store.detail) to link to the details page in the Store. 'store.detail': { - component: 'sw-extension-store-detail', - path: 'store/detail/:id', + path: 'store/extension/:id', meta: { parentPath: 'sw.extension.store', privilege: 'system.extension_store', }, - props: { - default: (route) => { - return { id: route.params.id }; + // Reuse the catch-all store route for initial rendering but forward the identifier. + // The identifier might be the extension's internal ID (int) or its technical name (str). + redirect: (route) => ({ + name: 'sw.extension.store', + state: { + extensionIdentifier: route.params.id, }, - }, + }), }, }, - /** - * Add routeMiddleware to add a redirect to the landing page - */ routeMiddleware(next, currentRoute) { if (currentRoute.name === 'sw.extension.store.landing-page') { currentRoute.redirect = { - name: 'sw.extension.store.listing', + name: 'sw.extension.store', }; } next(currentRoute); }, }); + +// Pre-fetch & cache iframe URL to speed up the initial loading of the admin store. +void Shopware.Application.viewInitialized.then(() => { + void extensionStoreContextStore().loadIframeUrl(); +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/index.js b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/index.js deleted file mode 100644 index 5577d514..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/index.js +++ /dev/null @@ -1,480 +0,0 @@ -import template from './sw-extension-store-detail.html.twig'; -import { - getLegacyServicesExtensionRoute, - isLegacyServicesExtension, -} from 'SwagExtensionStore/util/legacy-services-extension'; -import './sw-extension-store-detail.scss'; - -const { Utils } = Shopware; - -/** - * @private - */ -export default { - template, - - inject: [ - 'extensionStoreService', - 'extensionStoreDataService', - 'shopwareExtensionService', - 'extensionHelperService', - 'cacheApiService', - 'inAppPurchasesService', - ], - - mixins: ['sw-extension-error'], - - props: { - id: { - type: String, - required: true, - }, - }, - - data() { - return { - extension: null, - inAppPurchases: [], - isLoading: false, - showBuyModal: false, - showPermissionsModal: false, - showAcceptPermissionsModal: false, - showAccountModal: false, - showInstallationFailedModal: false, - showInAppPurchasesListingModal: false, - isInstalling: false, - isInstallSuccessful: false, - permissionsAccepted: false, - isDescriptionCollapsed: false, - installationError: null, - fetchError: false, - canBeOpened: false, - }; - }, - - computed: { - suspended() { - return this.extension === null; - }, - - calculatedPrice() { - return this.extensionStoreService.getCalculatedPrice(this.recommendedVariant); - }, - - calculatedPriceSnippet() { - return this.extensionStoreService.getCalculatedPriceSnippet(this.extension.variants); - }, - - extensionMetaData() { - if (this.suspended) { - return null; - } - - return Shopware.Store.get('shopwareExtensions').myExtensions.data.find((extension) => { - return extension.id === this.extension.id; - }); - }, - - isFree() { - return this.extensionStoreService.isVariantOfTypeFree(this.recommendedVariant); - }, - - isLicensed() { - if (!this.extensionMetaData) { - return false; - } - - return !!this.extensionMetaData.storeLicense; - }, - - isInstalled() { - return !!this.extensionMetaData && !!this.extensionMetaData.installedAt; - }, - - isConfigurable() { - return !!this.extensionMetaData && this.extensionMetaData.configurable; - }, - - images() { - if (this.suspended) { - return []; - } - - return this.extension.images.map((image) => { - return image.remoteLink; - }); - }, - - extensionCategoryNames() { - if (this.suspended) { - return ''; - } - - return this.extension.categories.map((category) => category.details.name).join(', '); - }, - - extensionLanguages() { - if (this.suspended) { - return ''; - } - - return this.extension.languages.join(', '); - }, - - isPurchasable() { - if (this.suspended) { - return false; - } - - if (!this.extension.variants.length) { - return false; - } - - return !this.isLicensed; - }, - - languageId() { - return Shopware.Store.get('session').languageId; - }, - - recommendedVariant() { - return this.extensionStoreService.getRecommendedVariant(this.extension.variants); - }, - - dateFilter() { - return Shopware.Filter.getByName('date'); - }, - - dateFilterOptions() { - return { - month: 'numeric', - year: 'numeric', - }; - }, - - hasActiveDiscount() { - return this.extensionStoreService.isExtensionDiscounted(this.extension.variants); - }, - - variantClass() { - return { - 'is--theme': this.extension && this.extension.isTheme, - }; - }, - - orderedBinaries() { - return Utils.get(this.extension, 'binaries', []).slice().reverse(); - }, - - priceClass() { - return { - 'sw-extension-store-detail__price-discounted': this.hasActiveDiscount, - }; - }, - - description() { - return Utils.get(this.extension, 'description'); - }, - - hasPermissions() { - return Object.keys(this.extension.permissions).length; - }, - - installationErrorDocumentationLink() { - return Utils.get(this.installationError, 'meta.documentationLink', null); - }, - - isEnterpriseFeature() { - if (this.suspended) { - return false; - } - - return !!this.extension.addons.find(addon => addon === 'SW6_EnterpriseFeature'); - }, - - isLegacyServicesExtension() { - if (this.suspended) { - return false; - } - - return isLegacyServicesExtension(this.extension.name); - }, - }, - - watch: { - id: { - immediate: true, - handler() { - this.fetchExtension(); - }, - }, - - '$route.hash'() { - this.scrollToElementFromHash(); - }, - - suspended() { - if (!this.suspended) { - // wait for all child components to be mounted - this.$nextTick(() => { - this.scrollToElementFromHash(); - }); - } - }, - - languageId(newValue) { - if (newValue !== '') { - this.fetchExtension(); - } - }, - - description() { - this.isDescriptionCollapsed = true; - this.$nextTick(() => { - this.checkDescriptionCollapsed(); - }); - }, - }, - - async created() { - await this.shopwareExtensionService.updateExtensionData(); - this.canBeOpened = !!this.shopwareExtensionService.getOpenLink(this.extension); - if (this.extension.inAppFeaturesAvailable) { - this.fetchInAppPurchases(); - } - }, - - methods: { - async fetchExtension() { - this.isLoading = true; - - if (this.languageId === '') { - return; - } - - try { - this.fetchError = false; - this.extension = await this.extensionStoreDataService.getDetail( - this.id, - { ...Shopware.Context.api, languageId: this.languageId }, - ); - } catch (error) { - const errorData = error.response.data.errors[0]; - - if (errorData.code === 'FRAMEWORK__STORE_ERROR' && errorData.title === 'Extension unknown') { - this.fetchError = errorData; - - - const docLink = this.$t('sw-extension.errors.messageToTheShopwareDocumentation', errorData.meta); - - this.createNotificationError({ - title: errorData.title, - message: `${errorData.detail}
    ${docLink}`, - autoClose: false, - }); - - return; - } - - this.showExtensionErrors(error); - } finally { - this.isLoading = false; - } - }, - - async fetchExtensionAndScrollToRatings() { - await this.fetchExtension(); - this.$router.push({ hash: '#ratings-card' }); - }, - - scrollToElementFromHash() { - if (!this.$route.hash) { - return; - } - - const elementWithHashId = this.$el.querySelector(this.$route.hash); - const pageBody = document.querySelector('div.sw-meteor-page__body'); - const header = document.querySelector('header.sw-meteor-page__head-area'); - - const topOfElement = elementWithHashId.offsetTop - header.getBoundingClientRect().height; - - pageBody.scroll({ top: topOfElement, behavior: 'smooth' }); - - // clear hash so user can click link again - this.$router.push({ ...this.route, hash: null }); - }, - - async onClickAddExtension() { - await this.shopwareExtensionService.checkLogin(); - - if (!Shopware.Store.get('shopwareExtensions').userInfo) { - this.openAccountModal(); - return; - } - - this.openBuyModal(); - }, - - renderPrice(price) { - return Utils.format.currency(price, 'EUR'); - }, - - onLoginSuccess() { - this.closeAccountModal(); - this.openBuyModal(); - }, - - openBuyModal() { - this.showBuyModal = true; - }, - - closeBuyModal() { - this.showBuyModal = false; - }, - - openAccountModal() { - this.showAccountModal = true; - }, - - closeAccountModal() { - this.showAccountModal = false; - }, - - openPermissionsModal() { - this.showPermissionsModal = true; - }, - - closePermissionsModal() { - this.showPermissionsModal = false; - }, - - openAcceptPermissionsModal() { - this.showAcceptPermissionsModal = true; - }, - - closeAcceptPermissionsModal() { - this.showAcceptPermissionsModal = false; - }, - - openInAppPurchasesListingModal() { - this.showInAppPurchasesListingModal = true; - }, - - closeInAppPurchasesListingModal() { - this.showInAppPurchasesListingModal = false; - }, - - async closePermissionsModalAndInstallExtension() { - this.permissionsAccepted = true; - this.closeAcceptPermissionsModal(); - await this.installExtension(); - }, - - async handleInstallWithPermissionsModal() { - if (this.isLegacyServicesExtension) { - this.openLegacyServicesExtension(); - - return; - } - - if (Object.keys(this.extension.permissions).length) { - this.openAcceptPermissionsModal(); - - return; - } - - this.permissionsAccepted = true; - await this.installExtension(); - }, - - async installExtension() { - if (this.isLegacyServicesExtension) { - this.openLegacyServicesExtension(); - - return; - } - - this.isInstalling = true; - - try { - await this.extensionHelperService.downloadAndActivateExtension(this.extension.name, this.extension.type); - - if (this.extension.type === 'plugin') { - await this.cacheApiService.clear(); - } - - this.isInstallSuccessful = true; - - window.location.reload(); - } catch (error) { - this.showExtensionErrors(error); - - if (Utils.get(error, 'response.data.errors[0]', null)) { - this.installationError = error.response.data.errors[0]; - } - - this.openInstallationFailedModal(); - } finally { - this.isInstalling = false; - } - }, - - async finishedInstall() { - await this.shopwareExtensionService.updateExtensionData(); - this.isInstallSuccessful = false; - }, - - async openExtension() { - const openLink = await this.shopwareExtensionService.getOpenLink(this.extension); - - if (openLink) { - this.$router.push(openLink); - } - }, - - openConfiguration() { - this.$router.push({ name: 'sw.extension.config', params: { namespace: this.extension.name } }); - }, - - openListingPage() { - if (this.extension && this.extension.isTheme) { - this.$router.push({ name: 'sw.extension.my-extensions.listing.theme' }); - return; - } - - this.$router.push({ name: 'sw.extension.my-extensions.listing.app' }); - }, - - openLegacyServicesExtension() { - const route = getLegacyServicesExtensionRoute(this.extension?.name); - - if (route) { - this.$router.push(route); - } - }, - - checkDescriptionCollapsed() { - const description = this.$el.querySelector('.sw-extension-store-detail__description'); - - if (description && description.scrollHeight <= 300) { - this.expandDescription(); - } - }, - - expandDescription() { - this.isDescriptionCollapsed = false; - }, - - openInstallationFailedModal() { - this.showInstallationFailedModal = true; - }, - - closeInstallationFailedModal() { - this.showInstallationFailedModal = false; - }, - - async fetchInAppPurchases() { - this.inAppPurchases = await this.inAppPurchasesService.getAvailablePurchases(this.extension.name); - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.html.twig deleted file mode 100644 index 32fcf8a7..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.html.twig +++ /dev/null @@ -1,611 +0,0 @@ -{% block sw_extension_store_detail %} - -
    - - - -

    {{ fetchError.title }}

    -

    {{ fetchError.detail }}

    -
    -
    - - - {% block sw_extension_store_detail_loader %}{% endblock %} - - - - {% block sw_extension_store_detail_smart_bar %} - {% block sw_extension_store_detail_slot_icon %} - - {% endblock %} - - {% block sw_extension_store_detail_slot_smart_bar_header %} - - {% endblock %} - - {% block sw_extension_store_detail_slot_smart_bar_header_meta %} - - {% endblock %} - - {% block sw_extension_store_detail_slot_smart_bar_description %} - - {% endblock %} - - {% block sw_extension_store_detail_slot_smart_bar_actions %} - - {% endblock %} - - {% block sw_extension_store_detail_slot_smart_bar_context_buttons %} - - {% endblock %} - {% endblock %} - - {% block sw_extension_store_detail_page_content %} - {% block sw_extension_sote_detail_page_info_alerts %} - - {{ $t('sw-extension-store.detail.enterpriseFeatureAlertText') }} - - {% endblock %} - - {% block sw_extension_store_detail_images %} - - {% endblock %} - - {% block sw_extension_store_detail_card_info %} - - {% block sw_extension_store_detail_card_info_content %} -
    - {% endblock %} - - {% block sw_extension_store_details_expand_description %} - - {{ $t('sw-extension-store.detail.labelButtonReadMore') }} - - {% endblock %} - - {% endblock %} - - {% block sw_extension_store_detail_card_details %} - - {% block sw_extension_store_detail_card_details_content %} -
    - {% block sw_extension_store_detail_card_details_category %} -
    {{ $t('sw-extension-store.detail.details.labelCategory', extension.categories.length) }}
    -
    {{ extensionCategoryNames }}
    - {% endblock %} - - {% block sw_extension_store_detail_card_details_version %} -
    {{ $t('sw-extension-store.detail.details.labelVersion') }}
    -
    {{ extension.latestVersion }}
    - {% endblock %} - - {% block sw_extension_store_detail_card_details_updated_at %} -
    {{ $t('sw-extension-store.detail.details.labelUpdatedAt') }}
    -
    {{ dateFilter(extension.lastUpdateDate, { month: 'numeric', year: 'numeric', hour: undefined, minute: undefined }) }}
    - {% endblock %} - - {% block sw_extension_store_detail_card_details_languages %} -
    - {{ $t('sw-extension-store.detail.details.labelLanguages') }} -
    -
    {{ extensionLanguages }}
    - {% endblock %} - - {% block sw_extension_store_detail_card_details_in_app_purchases %} - - {% endblock %} - - {% block sw_extension_store_detail_card_details_producer_website %} - - {% endblock %} - - {% block sw_extension_store_detail_card_details_privacy_policy %} - - {% endblock %} - - {% block sw_extension_store_detail_card_details_additional_entries %}{% endblock %} -
    - {% endblock %} -
    - {% endblock %} - - {% block sw_extension_store_detail_card_ratings %} - - {% endblock %} - - {% block sw_extension_store_detail_card_faq %} - - {% block sw_extension_store_detail_card_faq_questions %} -
    - {% block sw_extension_store_detail_card_faq_questions_question %} -

    {{ question.question }}

    - {% endblock %} - - {% block sw_extension_store_detail_card_faq_questions_answer %} -

    - {% endblock %} -

    - {% endblock %} -
    - {% endblock %} - - {% block sw_extension_store_detail_card_changelog %} - - {% block sw_extension_store_detail_card_changelog_entries %} -
    - {% block sw_extension_store_detail_card_changelog_entries_version %} -
    - {{ binary.version }} -
    - {% endblock %} - - {% block sw_extension_store_detail_card_changelog_entries_creation_date %} -
    - {{ dateFilter(binary.creationDate, { month: 'numeric', year: 'numeric', hour: undefined, minute: undefined }) }} -
    - {% endblock %} - - {% block sw_extension_store_detail_card_changelog_entries_changelog_text %} -

    - {% endblock %} -

    - {% endblock %} -
    - {% endblock %} - {% endblock %} - - {% block sw_extension_store_detail_page_modals %} - {% block sw_extension_store_detail_page_permission %} - - {% endblock %} - - {% block sw_extension_store_detail_page_permission_accept %} - - {% endblock %} - - {% block sw_extension_store_detail_page_account_modal %} - - {% endblock %} - {% endblock %} - -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.scss b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.scss deleted file mode 100644 index cd029ac2..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.scss +++ /dev/null @@ -1,339 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-detail__extension-unknown { - width: 960px; - max-width: 100%; - height: 450px; - background-color: var(--color-elevation-surface-raised); - margin: 72px auto 0 auto; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - text-align: center; - - .sw-label { - width: 72px; - height: 72px; - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 20px; - - .sw-label__caption { - width: auto; - } - } - - h2 { - font-size: $font-size-m; - } - - p { - color: var(--color-text-secondary-default); - font-size: $font-size-xs; - max-width: 325px; - margin-bottom: 1.5rem; - } -} - -.sw-meteor-page.sw-extension-store-detail { - .is--wrap-content { - word-break: break-word; - } - - .sw-extension-store-detail--user-provided-data { - h2, - h3, - h4, - h5, - h6 { - margin-top: 1.5em; - margin-bottom: 1em; - - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - } - - ul, - ol { - padding-left: 16px; - } - - a { - color: var(--color-text-brand-default); - } - } - - .sw-extension-icon { - width: 72px; - height: 72px; - } - - .sw-meteor-page__head-area { - padding-bottom: 12px; - - .sw-meteor-page__smart-bar-module-info { - grid-template-rows: min-content auto; - - .sw-meteor-page__smart-bar-header { - flex-wrap: nowrap; - flex-direction: column; - align-items: stretch; - - .sw-meteor-page__smart-bar-title { - font-size: $font-size-l; - font-weight: $font-weight-bold; - margin-bottom: 5px; - margin-top: -4px; - } - - .sw-meteor-page__smart-bar-meta { - color: var(--color-text-secondary-default); - font-size: $font-size-xxs; - display: flex; - flex-wrap: wrap; - align-items: baseline; - margin-bottom: 1px; - font-weight: $font-weight-medium; - gap: 8px; - } - } - - .sw-meteor-page__smart-bar-description { - color: var(--color-text-secondary-default); - font-size: $font-size-xs; - - .sw-extension-store-detail__label-and-rating-overview { - display: flex; - gap: 8px; - - .sw-extension-store-detail__rating-overview { - flex-shrink: 0; - align-self: start; - display: flex; - align-items: center; - - & > *:not(:last-child) { - margin-right: 8px; - } - - a, - a:active, - a:visited { - font-size: $font-size-xxs; - font-weight: $font-weight-medium; - color: var(--color-text-brand-default); - margin-top: 2px; - transition: color .2s ease; - - &:hover, - &:focus, - &:focus-visible, - &:focus-within { - color: var(--color-text-brand-hover); - } - } - - .sw-extension-store-detail__no-ratings-text { - font-size: $font-size-xxs; - font-weight: $font-weight-medium; - } - } - } - } - } - } - - .sw-extension-store-detail__extension-type-label, - .sw-extension-store-detail__label-display { - margin-right: 4px; - } - - .sw-extension-type-label__cloud-label { - background-color: var(--color-background-tertiary-default) !important; - } - - .sw-extension-store-detail__producer-link { - text-decoration: none; - font-weight: $font-weight-medium; - } - - .sw-meteor-page__smart-bar-actions { - .sw-extension-store-detail__trial-info { - font-size: $font-size-xs; - font-weight: $font-weight-regular; - color: var(--color-text-brand-default); - padding: 0; - border: none; - background: none; - line-height: inherit; - text-align: right; - margin: 0 0 10px 0; - } - - .sw-extension-store-detail__price { - color: var(--color-text-secondary-default); - - strong { - color: var(--color-text-primary-default); - } - - &-badge-sale { - background-color: var(--color-background-critical-default); - border-radius: $border-radius-pill; - color: var(--color-text-critical-default); - display: inline-block; - font-size: $font-size-xxs; - font-weight: $font-weight-medium; - height: $line-height-xs; - line-height: $line-height-xs; - margin-right: 4px; - padding: 0 5px; - vertical-align: bottom; - } - - &-discounted { - strong { - color: var(--color-text-critical-default); - } - } - } - - .sw-extension-store-detail__in-app-purchases__badge { - margin: 0; - font-size: 14px; - } - - .sw-extension-store-detail__price-free { - margin-bottom: 0; - } - - .sw-extension-store-detail__discounted-price-info { - color: var(--color-text-secondary-default); - font-size: $font-size-xxs; - margin-top: 0; - text-align: right; - } - } - - - &.is--theme .sw-extension-store-slider .sw-extension-store-slider__slides { - height: 70vw; - max-height: 720px; - } - - .sw-extension-store-detail__description { - &--collapsed { - max-height: 300px; - overflow-y: hidden; - } - } - - .sw-extension-store-detail__button-extend-description { - margin-top: 16px; - } - - .sw-extension-store-detail__details-list { - display: grid; - grid-template-columns: auto 1fr; - justify-items: start; - column-gap: 40px; - row-gap: 8px; - - dt { - font-weight: $font-weight-semi-bold; - - a { - font-weight: $font-weight-regular; - } - } - } - - .sw-extension-store-detail__faq-question h4 { - margin-bottom: 5px; - } - - .sw-extension-store-detail__faq-question:not(:last-child) { - margin-bottom: 1.5em; - } - - .sw-extension-store-detail__changelog-entry { - display: grid; - grid-template-columns: 96px 1fr; - grid-template-rows: auto 1fr; - column-gap: 40px; - grid-template-areas: - "version changelog" - "date changelog"; - } - - .sw-extension-store-detail__changelog-entry:not(:last-child) { - margin-bottom: 32px; - padding-bottom: 32px; - border-bottom: 1px dashed var(--color-border-primary-default); - } - - .sw-extension-store-detail__changelog-version { - grid-area: version; - justify-self: right; - text-align: right; - font-size: $font-size-s; - font-weight: $font-weight-bold; - } - - .sw-extension-store-detail__changelog-creation-date { - grid-area: date; - justify-self: right; - text-align: right; - font-size: $font-size-xxs; - font-weight: $font-weight-medium; - color: var(--color-text-secondary-default); - } - - .sw-extension-store-detail__changelog-text { - grid-area: changelog; - font-size: $font-size-xs; - p, ul, ol { - margin-bottom: 12px; - li:not(:last-child) { - margin-bottom: 8px; - } - } - ul { - list-style: none; - li { - margin-left: 16px; - position: relative; - &:before{ - content: "\2022"; - position: absolute; - left: -16px; - } - } - } - ol { - list-style-position: inside; - } - } - - .sw-extension-store-detail__alert { - max-width: 960px; - margin: 0 auto 40px auto; - } -} - -.sw-extension-store-detail__account-modal { - .sw-modal__body { - padding: 0; - } - - .sw-meteor-card { - margin: 0; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.spec.js deleted file mode 100644 index 6aad47ce..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-detail/sw-extension-store-detail.spec.js +++ /dev/null @@ -1,710 +0,0 @@ -import { mount } from '@vue/test-utils'; -import ShopwareExtensionService from 'src/module/sw-extension/service/shopware-extension.service'; -import ShopwareDiscountCampaignService from 'src/app/service/discount-campaign.service'; -import ExtensionStoreService from 'SwagExtensionStore/module/sw-extension-store/service/extension-store.service'; - -Shopware.Component.register( - 'sw-extension-store-detail', - () => import('SwagExtensionStore/module/sw-extension-store/page/sw-extension-store-detail'), -); - -const cacheApiService = { - clear: jest.fn(() => Promise.resolve()), -}; - -const extensionHelperService = { - downloadAndActivateExtension: jest.fn(() => Promise.resolve()), -}; - -const router = { - push: jest.fn(), -}; - -async function createWrapper(extensionCustomProps = {}, canBeOpened = true, inAppPurchases = true) { - const testExtension = { - id: 1337, - categories: [ - { details: { name: 'Productivity' } }, - { details: { name: 'Admin' } }, - { details: { name: 'Storefront' } }, - ], - description: '

    This is a really cool extension.

    ', - inAppFeaturesAvailable: inAppPurchases, - label: 'B2B Suite', - name: 'SwagB2BPlatform', - producerName: 'shopware AG', - shortDescription: 'Foo', - type: 'plugin', - updateSource: 'local', - variants: [{}], - languages: ['German', 'English'], - storeLicense: { variants: [{}] }, - images: [], - permissions: [], - labels: [], - faq: [], - addons: [], - ...extensionCustomProps, - }; - - const inAppPurchasesService = getInAppPurchasesMockService(inAppPurchases); - - return mount(await Shopware.Component.build('sw-extension-store-detail'), { - props: { - id: 'a1b2c3', - }, - global: { - renderStubDefaultSlot: true, - stubs: { - /* sw-meteor-page */ - 'sw-meteor-page': await wrapTestComponent('sw-meteor-page'), - 'sw-notification-center': true, - 'sw-help-center-v2': true, - 'sw-meteor-page-context': true, - 'sw-meteor-navigation': true, - 'sw-tabs': true, - 'sw-extension-component-section': true, - 'sw-app-topbar-button': true, - 'sw-search-bar': { - template: '', - }, - /* sw-meteor-page */ - - 'sw-loader': true, - 'sw-extension-type-label': true, - 'sw-extension-store-slider': true, - 'sw-meteor-card': true, - 'sw-label': await wrapTestComponent('sw-label'), - 'sw-button-group': true, - 'sw-context-button': true, - 'sw-context-menu-item': true, - 'sw-extension-ratings-card': true, - 'sw-button-process': await wrapTestComponent('sw-button-process'), - 'sw-extension-rating-stars': true, - 'router-link': { - template: '', - }, - 'sw-extension-store-label-display': true, - 'sw-extension-buy-modal': true, - 'sw-extension-permissions-modal': true, - 'sw-extension-my-extensions-account': true, - 'sw-extension-adding-failed': true, - 'sw-extension-store-in-app-purchases-listing-modal': true, - 'sw-extension-icon': true, - 'sw-external-link': true, - 'sw-app-topbar-sidebar': true, - }, - mocks: { - $router: router, - }, - provide: { - shopwareExtensionService: { - updateExtensionData: jest.fn(), - isVariantDiscounted: jest.fn(), - orderVariantsByRecommendation: () => [], - getOpenLink: () => (canBeOpened ? 'open-link' : null), - }, - extensionStoreService: new ExtensionStoreService( - new ShopwareDiscountCampaignService(), - new ShopwareExtensionService( - undefined, - undefined, - new ShopwareDiscountCampaignService(), - ), - ), - extensionStoreDataService: { - getDetail: () => { - return testExtension; - }, - }, - extensionHelperService, - cacheApiService, - inAppPurchasesService, - }, - }, - }); -} - -const setSearchValueMock = jest.fn(); -describe('SwagExtensionStore/module/sw-extension-store/page/sw-extension-store-detail', () => { - const originalWindowLocation = window.location; - - beforeAll(async () => { - Shopware.Store.register({ - id: 'shopwareExtensions', - actions: { - setSearchValue: setSearchValueMock, - }, - }); - - Object.defineProperty(window, 'location', { - configurable: true, - value: { reload: jest.fn() }, - }); - }); - - beforeEach(() => { - Shopware.Store.get('session').languageId = 'b2c3d4'; - Shopware.Store.get('shopwareExtensions').myExtensions = { - loading: false, - data: [{ - active: true, - name: 'SwagB2BPlatform', - storeLicense: { variants: [{}] }, - id: 1337, - }], - }; - - setSearchValueMock.mockClear(); - extensionHelperService.downloadAndActivateExtension.mockClear(); - cacheApiService.clear.mockClear(); - window.location.reload.mockClear(); - router.push.mockClear(); - }); - - afterEach(() => { - Shopware.Store.get('session').languageId = ''; - Shopware.Store.get('shopwareExtensions').myExtensions = { data: [], loading: false }; - }); - - afterAll(() => { - Object.defineProperty(window, 'location', { configurable: true, value: originalWindowLocation }); - }); - - it('should show all extension category names', async () => { - const wrapper = await createWrapper(); - - expect(wrapper.vm.extensionCategoryNames).toBe('Productivity, Admin, Storefront'); - }); - - it('should render alert box when extension is an enterprise feature', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SwagB2BPlatform', - storeLicense: false, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - storeLicense: false, - addons: ['SW6_EnterpriseFeature'], - variants: [], - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__alert').text()) - .toBe('sw-extension-store.detail.enterpriseFeatureAlertText'); - }); - - it('should clear cache and reload the administration when a plugin is installed', async () => { - const wrapper = await createWrapper(); - await flushPromises(); - - const installButton = await wrapper.get('.sw-extension-store-detail__action-install-extension'); - await installButton.trigger('click'); - await flushPromises(); - - expect(extensionHelperService.downloadAndActivateExtension).toHaveBeenCalledTimes(1); - expect(cacheApiService.clear).toHaveBeenCalledTimes(1); - expect(window.location.reload).toHaveBeenCalledTimes(1); - }); - - it('should reload the administration when an app is installed', async () => { - const wrapper = await createWrapper({ type: 'app' }); - await flushPromises(); - - const installButton = await wrapper.get('.sw-extension-store-detail__action-install-extension'); - await installButton.trigger('click'); - await flushPromises(); - - expect(extensionHelperService.downloadAndActivateExtension).toHaveBeenCalledTimes(1); - expect(cacheApiService.clear).not.toHaveBeenCalled(); - expect(window.location.reload).toHaveBeenCalledTimes(1); - }); - - describe('verify smart bar price', () => { - beforeEach(() => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SomeOtherExtension', - storeLicense: { variants: [{}] }, - id: 555, - }], - }; - }); - - it('should render "free" when extension is not licensed and free', async () => { - const wrapper = await createWrapper({ - variants: [{ type: 'free' }], - storeLicense: null, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__price-free').text()) - .toBe('sw-extension-store.general.labelFree'); - }); - - it('should render monthly price when extension is not licensed', async () => { - const wrapper = await createWrapper({ - variants: [{ - type: 'rent', - netPrice: 10, - netPricePerMonth: 10, - duration: 1, - }], - storeLicense: null, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__price').text()) - .toContain('sw-extension-store.general.labelPricePerMonth'); - }); - - it('should render monthly from price when extension is not licensed', async () => { - const wrapper = await createWrapper({ - variants: [{ - type: 'rent', - netPrice: 10, - netPricePerMonth: 10, - duration: 1, - }, { - type: 'rent', - netPrice: 96, - netPricePerMonth: 8, - duration: 12, - }], - storeLicense: null, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__price').text()) - .toContain('sw-extension-store.general.labelFromPricePerMonth'); - }); - - it('should render sale badge when extension is not licensed and has active discount', async () => { - const wrapper = await createWrapper({ - variants: [{ - type: 'rent', - netPrice: 10, - netPricePerMonth: 10, - duration: 1, - discountCampaign: { - discountedPrice: 6, - discountedPricePerMonth: 6, - discountAppliesForMonths: 3, - startDate: '2021-01-27T00:01:00+01:00', - endDate: '2121-01-28T00:01:00+01:00', - }, - }, { - type: 'rent', - netPrice: 96, - netPricePerMonth: 8, - duration: 12, - }], - storeLicense: null, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__price').text()) - .toContain('sw-extension-store.general.labelSale'); - }); - }); - - describe('verify smart bar primary action buttons', () => { - it('should render "add extension" button when extension is purchasable', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'TestExtension', - storeLicense: false, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - storeLicense: null, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__action-add-extension').text()) - .toBe('sw-extension-store.detail.labelButtonAddExtension'); - }); - - it('should render "install" button when extension is not installed but already licensed', async () => { - const wrapper = await createWrapper(); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__action-install-extension').text()) - .toBe('sw-extension-store.detail.labelButtonInstallExtension'); - }); - - it('renders a Shopware Services handoff for licensed Shopware Payments', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'ShopwarePayments', - storeLicense: { variants: [{}] }, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - label: 'Shopware Payments', - name: 'ShopwarePayments', - type: 'app', - }); - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail__action-install-extension').exists()).toBe(false); - expect(wrapper.get('.sw-extension-store-detail__services-handoff-text').text()) - .toBe('sw-extension-store.legacyServices.description'); - expect(wrapper.get('.sw-extension-store-detail__action-open-services').text()) - .toBe('sw-extension-store.legacyServices.openServices'); - }); - - it('routes the Shopware Services handoff to the Shopware Payments administration module', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'ShopwarePayments', - storeLicense: { variants: [{}] }, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - label: 'Shopware Payments', - name: 'ShopwarePayments', - type: 'app', - }); - await flushPromises(); - - await wrapper.get('.sw-extension-store-detail__action-open-services').trigger('click'); - - expect(router.push).toHaveBeenCalledWith({ - name: 'sw.extension.module', - params: { - appName: 'ShopwarePayments', - moduleName: 'sw-shopware-payments-overview', - }, - }); - }); - - it('does not start the Store install flow for guarded Shopware Payments install methods', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'ShopwarePayments', - storeLicense: { variants: [{}] }, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - label: 'Shopware Payments', - name: 'ShopwarePayments', - type: 'app', - }); - await flushPromises(); - - await wrapper.vm.handleInstallWithPermissionsModal(); - await wrapper.vm.installExtension(); - - expect(extensionHelperService.downloadAndActivateExtension).not.toHaveBeenCalled(); - expect(router.push).toHaveBeenCalledTimes(2); - }); - - it('keeps the regular install button for other licensed apps', async () => { - const wrapper = await createWrapper({ type: 'app' }); - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail__services-handoff').exists()).toBe(false); - expect(wrapper.get('.sw-extension-store-detail__action-install-extension').text()) - .toBe('sw-extension-store.detail.labelButtonInstallExtension'); - }); - - it('should render "open" button when extension is installed and licensed', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SwagB2BPlatform', - storeLicense: { variants: [{}] }, - id: 1337, - installedAt: { - date: '2021-07-08 07:34:11.794000', - timezone: 'UTC', - timezone_type: 3, - }, - }], - }; - - const wrapper = await createWrapper({ - installedAt: { - date: '2021-07-08 07:34:11.794000', - timezone: 'UTC', - timezone_type: 3, - }, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__action-open-extension').text()) - .toBe('sw-extension-store.detail.labelButtonOpenExtension'); - }); - - - it('should render "configuration" context menu when extension is installed, licensed and configurable', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SwagB2BPlatform', - storeLicense: { variants: [{}] }, - id: 1337, - configurable: true, - installedAt: { - date: '2021-07-08 07:34:11.794000', - timezone: 'UTC', - timezone_type: 3, - }, - }], - }; - - const wrapper = await createWrapper({ - installedAt: { - date: '2021-07-08 07:34:11.794000', - timezone: 'UTC', - timezone_type: 3, - }, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__action-context').text()) - .toBe('sw-extension-store.detail.openConfiguration'); - }); - - - it('should render "configuration" button when extension is installed, licensed and configurable but can\'t be opened', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SwagB2BPlatform', - storeLicense: { variants: [{}] }, - configurable: true, - id: 1337, - installedAt: { - date: '2021-07-08 07:34:11.794000', - timezone: 'UTC', - timezone_type: 3, - }, - }], - }; - - const wrapper = await createWrapper({ - installedAt: { - date: '2021-07-08 07:34:11.794000', - timezone: 'UTC', - timezone_type: 3, - }, - }, false); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__action-open-configuration').text()) - .toBe('sw-extension-store.detail.openConfiguration'); - }); - - it('should render "contact us" button when extension is not licensed and has enterprise flag', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SwagB2BPlatform', - storeLicense: null, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - storeLicense: null, - addons: ['SW6_EnterpriseFeature'], - variants: [], - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__action-enterprise-contact').text()) - .toBe('sw-extension-store.detail.enterpriseContactLinkText'); - }); - - it('should render "free" when extension is not licensed and free', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SomeOtherExtension', - storeLicense: { variants: [{}] }, - id: 555, - }], - }; - - const wrapper = await createWrapper({ - variants: [{ type: 'free' }], - storeLicense: null, - }); - await flushPromises(); - - expect(wrapper.get('.sw-extension-store-detail__price-free').text()) - .toBe('sw-extension-store.general.labelFree'); - }); - - it('should not render any button when extension is not licensed, not purchasable and has no enterprise flag', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'SomeOtherExtension', - storeLicense: { variants: [{}] }, - id: 555, - }], - }; - - const wrapper = await createWrapper({ - storeLicense: null, - variants: [], - }); - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail__action-add-extension').exists()).toBeFalsy(); - expect(wrapper.find('.sw-extension-store-detail__action-open-extension').exists()).toBeFalsy(); - expect(wrapper.find('.sw-extension-store-detail__action-install-extension').exists()).toBeFalsy(); - expect(wrapper.find('.sw-extension-store-detail__action-enterprise-contact').exists()).toBeFalsy(); - }); - - it('should render in-app-purchase badge when extension has available in-app-purchase', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'TestExtension', - storeLicense: false, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - inAppFeaturesAvailable: true, - variants: [{ foo: 'bar' }], - }); - - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail__in-app-purchases__badge').exists()).toBeTruthy(); - expect(wrapper.get('.sw-extension-store-detail__in-app-purchases__badge').text()) - .toBe('sw-extension.in-app-purchase.badge-label'); - }); - - it('should not render in-app-purchase badge when extension has available in-app-purchase', async () => { - Shopware.Store.get('shopwareExtensions').myExtensions = { - data: [{ - active: true, - name: 'TestExtension', - storeLicense: false, - id: 1337, - }], - }; - - const wrapper = await createWrapper({ - inAppFeaturesAvailable: false, - variants: [{ foo: 'bar' }], - }); - - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail__in-app-purchases__badge').exists()).toBeFalsy(); - }); - - it('should not render in-app purchases section when not available', async () => { - const wrapper = await createWrapper({}, true, false); - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail-card-details-in-app-purchases__count').exists()).toBe(false); - }); - - it('should render in-app purchases section when available', async () => { - const wrapper = await createWrapper(); - await flushPromises(); - - expect(wrapper.find('.sw-extension-store-detail-card-details-in-app-purchases__count').exists()).toBe(true); - expect(wrapper.get('.sw-extension-store-detail-card-details-in-app-purchases__count').text()).toBe('2'); - }); - }); -}); - -function getInAppPurchasesMockService(inAppPurchases) { - return { - getAvailablePurchases: jest.fn(() => getInAppPurchaseMockResponse(inAppPurchases)), - }; -} - -function getInAppPurchaseMockResponse(inAppPurchases) { - if (!inAppPurchases) { - return []; - } - return [ - { - identifier: 'feature1', - name: 'Feature One', - description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at magna commodo, sodales ' + - 'mauris ut, aliquam dolor. Proin tellus nunc, tempor eget blandit vel, elementum quis velit. ' + - 'Curabitur bibendum consequat odio, sed aliquam lacus vestibulum in. Nam eleifend sollicitudin ' + - 'lorem, gravida fringilla urna consectetur id. Nullam lacus nunc, fringilla at odio id, luctus porta ' + - 'dolor. Ut varius pretium ex, non porta mi vulputate id. Suspendisse vel maximus turpis, nec lacinia ' + - 'dolor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis ' + - 'egestas. Ut vel quam placerat, ultricies nunc ac, dapibus urna. Sed tincidunt ullamcorper arcu nec ' + - 'tempus. Aenean elementum augue eget cursus cursus. Donec vitae elementum elit. Nunc ac sem in ante ' + - 'hendrerit vulputate vel id tortor. Curabitur mollis vulputate urna, vel viverra lorem. Nunc sed ' + - 'neque at libero ornare commodo.\n\n' + - 'Etiam fringilla ornare mauris in fermentum. Aliquam dapibus facilisis nisi at porta. Quisque ' + - 'dignissim sem ipsum, in efficitur metus blandit non. Donec sodales eleifend ipsum nec viverra. ' + - 'Etiam vitae ipsum magna. Nam mi ante, dignissim eget malesuada nec, feugiat vel ipsum. Phasellus ' + - 'augue erat, imperdiet non odio a, luctus lacinia est. Nunc blandit purus sit amet ante vehicula ' + - 'accumsan. Duis neque metus, consequat non enim at, porta maximus risus. Duis euismod odio sapien, ' + - 'in feugiat magna ultricies vel. Curabitur dictum condimentum ligula dapibus faucibus.\n\n' + - 'Donec augue risus, vulputate in ipsum nec, gravida eleifend ante. Quisque maximus sapien dui, eget ' + - 'mollis dolor gravida ac. Proin a enim tincidunt, maximus erat ac, fermentum nisi. Nullam molestie ' + - 'eros vel ipsum viverra, sit amet congue nunc feugiat. Nullam elementum lorem libero, in scelerisque ' + - 'nunc congue in. Aliquam in dolor suscipit, pellentesque leo vel, malesuada velit. Ut felis mauris, ' + - 'lacinia ut efficitur ut, consectetur at ipsum. Nam dui magna, fringilla id semper sed, vulputate ' + - 'vel nunc. Duis quis metus ante.\n\n' + - 'Sed at enim eu velit vestibulum efficitur ac non nunc. Vestibulum in maximus ligula, ac condimentum ' + - 'erat. Proin mauris nunc, ullamcorper quis lorem vitae, porta convallis orci. Maecenas vitae lectus ' + - 'mauris. Cras leo nisl, finibus vitae varius vitae, maximus eget erat. In vitae tristique libero. ' + - 'Nam eget eleifend leo.', - priceModels: [ - { - price: 9.99, - currency: 'USD', - duration: 'monthly', - }, - { - price: 99.99, - currency: 'USD', - duration: 'yearly', - }, - ], - }, - { - identifier: 'feature2', - name: 'Feature Two', - description: 'Description for Feature Two', - priceModels: [ - { - price: 4.99, - currency: 'USD', - duration: 'monthly', - }, - { - price: 49.99, - currency: 'USD', - duration: 'yearly', - }, - ], - }, - ]; -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/index.js b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/index.js index c0efc4dc..e7f37277 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/index.js +++ b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/index.js @@ -1,5 +1,8 @@ import template from './sw-extension-store-index.html.twig'; import './sw-extension-store-index.scss'; +import extensionStoreContextStore + from 'SwagExtensionStore/module/sw-extension-store/store/extension-store-context.store'; +import { trackExtensionStoreEvent } from 'SwagExtensionStore/util/telemetry'; /** * @private @@ -7,117 +10,23 @@ import './sw-extension-store-index.scss'; export default { template, - inject: ['extensionStoreActionService', 'shopwareExtensionService', 'feature'], - - props: { - id: { - type: String, - required: false, - default: null, - }, - }, - - data() { - return { - isAvailable: false, - failReason: '', - listingError: null, - isLoading: false, - }; - }, + inject: [ + 'extensionStoreChannelService', + ], computed: { - storeSearchKey() { - return this.$route.name; - }, - - activeFilters() { - return Shopware.Store.get('shopwareExtensions').search.filter; - }, - - searchValue() { - return Shopware.Store.get('shopwareExtensions').search.term; - }, - - isTheme() { - const isTheme = this.$route.name.includes('theme'); - - return isTheme ? 'themes' : 'apps'; - }, - }, - - watch: { - isTheme: { - immediate: true, - handler(newValue) { - Shopware.Store.get('shopwareExtensions').setSearchValue({ - key: 'page', - value: this.isTheme === this.activeFilters.group ? Shopware.Store.get('shopwareExtensions').search.page : 1, - }); - this.activeFilters.group = newValue; - }, + storeUrl() { + return extensionStoreContextStore().iframeUrl; }, }, created() { - this.createdComponent(); + trackExtensionStoreEvent('extension_store_entered'); + this.extensionStoreChannelService.register(); }, - methods: { - createdComponent() { - this.checkStoreUpdates(); - }, - - async checkStoreUpdates() { - this.isLoading = true; - - this.shopwareExtensionService.updateExtensionData(); - - const extensionStore = await this.getExtensionStore(); - - if (!extensionStore) { - this.isLoading = false; - return; - } - - if (this.isUpdateable(extensionStore)) { - this.isAvailable = false; - this.failReason = 'outdated'; - this.isLoading = false; - return; - } - - this.isAvailable = true; - this.isLoading = false; - }, - - onExtensionListingError(e) { - const errors = Shopware.Service('extensionErrorService').handleErrorResponse(e, this); - - this.isAvailable = false; - this.listingError = errors && errors[0]; - this.failReason = 'listing_error'; - }, - - getExtensionStore() { - return this.extensionStoreActionService.getMyExtensions().then((extensions) => { - return extensions.find(extension => extension.name === 'SwagExtensionStore'); - }); - }, - - isUpdateable(extension) { - if (!extension || extension.latestVersion === null) { - return false; - } - - return extension.latestVersion !== extension.version; - }, - - updateSearch(term) { - Shopware.Store.get('shopwareExtensions').setSearchValue({ - key: 'term', - value: term, - }); - }, + beforeUnmount() { + trackExtensionStoreEvent('extension_store_left'); + this.extensionStoreChannelService.unregister(); }, }; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.html.twig index 5ffca039..a7fd8b6e 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.html.twig +++ b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.html.twig @@ -1,83 +1,10 @@ {% block sw_extension_store_index %} - - {% block sw_extension_store_index_slots %} - {% block sw_extension_store_index_slot_search_bar %} - - {% endblock %} - - {% block sw_extension_store_index_slot_tabs %} - - {% endblock %} - - {% block sw_extension_store_index_slot_default %} - - {% endblock %} - {% endblock %} - + + + {% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.scss b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.scss index 1f2e745a..3d0d1b12 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.scss +++ b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.scss @@ -1,5 +1,3 @@ -.sw-extension-store-index { - .is--wrap-content { - word-break: break-word; - } +.sw-extension-store-iframe { + height: 100%; } diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.spec.js b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.spec.js index 60148ad5..b60dea6e 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.spec.js +++ b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-index/sw-extension-store-index.spec.js @@ -1,30 +1,26 @@ import { mount } from '@vue/test-utils'; -import ExtensionErrorService from 'src/module/sw-extension/service/extension-error.service'; import 'src/app/component/meteor/sw-meteor-page'; +const mockStore = { + iframeUrl: null, +}; + +jest.mock('SwagExtensionStore/module/sw-extension-store/store/extension-store-context.store', () => jest.fn(() => mockStore)); +jest.mock('SwagExtensionStore/util/telemetry', () => ({ trackExtensionStoreEvent: jest.fn() })); + Shopware.Component.register( 'sw-extension-store-index', () => import('SwagExtensionStore/module/sw-extension-store/page/sw-extension-store-index'), ); -Shopware.Component.register( - 'sw-extension-store-detail', - () => import('SwagExtensionStore/module/sw-extension-store/page/sw-extension-store-detail'), -); -const myExtensionsMock = jest.fn(() => Promise.resolve([{ - name: 'SwagExtensionStore', - latestVersion: null, - version: '1.3.0', -}])); - -Shopware.Application.addServiceProvider('extensionErrorService', () => { - return new ExtensionErrorService({}, { - title: 'global.default.error', - message: 'global.notification.unspecifiedSaveErrorMessage', - }); -}); +async function createWrapper(channelService = null) { + if (channelService === null) { + channelService = { + register: jest.fn(), + unregister: jest.fn(), + }; + } -async function createWrapper() { return mount(await Shopware.Component.build('sw-extension-store-index'), { props: {}, global: { @@ -38,61 +34,19 @@ async function createWrapper() { }), }, stubs: { - /* sw-meteor-page */ - 'sw-meteor-page': await wrapTestComponent('sw-meteor-page'), - 'sw-notification-center': true, - 'sw-help-center-v2': true, - 'sw-meteor-page-context': true, - 'sw-meteor-navigation': true, - 'sw-tabs': true, - 'sw-extension-component-section': true, - 'sw-app-topbar-button': true, - 'sw-search-bar': { - template: '', - }, - /* sw-meteor-page */ - - 'sw-loader': true, - 'sw-tabs-item': true, - 'router-view': true, - 'sw-extension-store-error-card': true, - 'sw-extension-store-update-warning': true, - 'sw-help-center': true, - 'sw-app-topbar-sidebar': true, + 'sw-iframe-renderer': true, + 'sw-extension-store-purchase-confirmation-modal': true, }, provide: { - extensionStoreActionService: { - getMyExtensions: myExtensionsMock, - }, - shopwareExtensionService: { - updateExtensionData: jest.fn(), - }, - extensionErrorService: Shopware.Service('extensionErrorService'), + extensionStoreChannelService: channelService, }, }, }); } -const setSearchValueMock = jest.fn(); describe('SwagExtensionStore/module/sw-extension-store/page/sw-extension-store-index', () => { - beforeAll(async () => { - Shopware.Store.register({ - id: 'shopwareExtensions', - state: () => ({ - search: { - filter: {}, - }, - }), - actions: { - setSearchValue: setSearchValueMock, - }, - }); - }); - - beforeEach(async () => { - Shopware.Store.get('shopwareExtensions').search.filter = {}; - setSearchValueMock.mockClear(); - myExtensionsMock.mockClear(); + afterEach(() => { + mockStore.iframeUrl = null; }); it('should be a Vue.JS component', async () => { @@ -101,99 +55,40 @@ describe('SwagExtensionStore/module/sw-extension-store/page/sw-extension-store-i expect(wrapper.vm).toBeTruthy(); }); - it('should commit the search value to the store', async () => { - const wrapper = await createWrapper(); - await flushPromises(); - - expect(setSearchValueMock).toHaveBeenCalledTimes(1); - expect(setSearchValueMock).toHaveBeenCalledWith({ - key: 'page', - value: 1, - }); - setSearchValueMock.mockClear(); - - const searchBar = wrapper.getComponent('.sw-search-bar'); - await searchBar.vm.$emit('search', 'Nice theme'); - - expect(setSearchValueMock).toHaveBeenCalledWith({ - key: 'term', - value: 'Nice theme', - }); - }); - - it('should filter to only app extensions', async () => { - await createWrapper(); - - const filter = Shopware.Store.get('shopwareExtensions').search.filter; + it('should use the register service method', async () => { + const channelService = { + register: jest.fn(), + unregister: jest.fn(), + }; + await createWrapper(channelService); - expect(filter).toEqual({ - group: 'apps', - }); + expect(channelService.register).toBeCalled(); }); - it('should filter to only theme extensions', async () => { - const wrapper = await createWrapper(); - - wrapper.vm.$route.name = 'sw.extension.store.listing.theme'; - await flushPromises(); + it('should unregister the channel service on unmount', async () => { + const channelService = { + register: jest.fn(), + unregister: jest.fn(), + }; + const wrapper = await createWrapper(channelService); - const filter = Shopware.Store.get('shopwareExtensions').search.filter; + await wrapper.unmount(); - expect(filter).toEqual({ - group: 'themes', - }); + expect(channelService.unregister).toBeCalled(); }); - it('should show update message when newer version is available', async () => { - // Mock higher `latestVersion` to show update card - myExtensionsMock.mockImplementationOnce(() => Promise.resolve([{ - name: 'SwagExtensionStore', - latestVersion: '1.4.0', - version: '1.3.0', - }])); - + it('should not render the iframe while the store URL is loading', async () => { const wrapper = await createWrapper(); - // Wait for loader to disappear - await flushPromises(); - - expect(wrapper.get('sw-extension-store-update-warning-stub').exists()).toBe(true); + expect(wrapper.find('sw-iframe-renderer-stub').exists()).toBe(false); }); - it('should show listing errors on listing errors event', async () => { - const wrapper = await createWrapper(); - - // Wait for loader to disappear - await flushPromises(); - - // Mock listing error response - const listingError = new Error(); - listingError.response = { - data: { - errors: [ - { - code: 'FRAMEWORK__STORE_ERROR', - detail: 'The given Shopware version is unknown, please contact our customer service', - meta: { - documentationLink: 'https://docs.shopware.com', - }, - status: '500', - title: 'Shopware version is unknown', - }, - ], - }, - }; + it('should render the iframe once the store URL is available', async () => { + mockStore.iframeUrl = 'https://iframe.shopware.com'; - // Emit listing error on router view - await wrapper.getComponent('router-view-stub').vm.$emit('extension-listing-errors', listingError); - - expect(wrapper.get('sw-extension-store-error-card-stub').attributes().title) - .toBe('Shopware version is unknown'); - - expect(wrapper.get('sw-extension-store-error-card-stub').attributes().variant) - .toBe('danger'); + const wrapper = await createWrapper(); - expect(wrapper.get('sw-extension-store-error-card-stub').text()) - .toBe('The given Shopware version is unknown, please contact our customer service'); + expect(wrapper.find('sw-iframe-renderer-stub').exists()).toBe(true); + expect(wrapper.find('sw-iframe-renderer-stub').attributes('src')).toBe('https://iframe.shopware.com'); }); }); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/index.js b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/index.js deleted file mode 100644 index 82527a33..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/index.js +++ /dev/null @@ -1,106 +0,0 @@ -import template from './sw-extension-store-listing.html.twig'; -import './sw-extension-store-listing.scss'; - -/** - * @private - */ -export default { - name: 'sw-extension-store-listing', - template, - - inject: ['feature'], - - emits: ['extension-listing-errors'], - - mixins: ['sw-extension-error'], - - data() { - return { - isLoading: false, - }; - }, - - computed: { - extensions() { - return Shopware.Store.get('shopwareExtensions').extensionListing; - }, - - currentSearch() { - return Shopware.Store.get('shopwareExtensions').search; - }, - - page() { - return this.currentSearch.page; - }, - - limit() { - return this.currentSearch.limit; - }, - - total() { - return this.extensions.total || 0; - }, - - rating() { - return this.currentSearch.rating; - }, - - languageId() { - return Shopware.Store.get('session').languageId; - }, - - currentLocale() { - return Shopware.Store.get('session').currentLocale === 'de-DE' ? 'de' : 'en'; - }, - }, - - watch: { - currentSearch: { - deep: true, - immediate: true, - handler() { - this.getList(); - }, - }, - languageId(newValue) { - if (newValue !== '') { - this.getList(); - } - }, - }, - - methods: { - async getList() { - this.isLoading = true; - - if (this.languageId === '') { - return; - } - - try { - await this.search(); - } catch (e) { - this.showExtensionErrors(e); - this.$emit('extension-listing-errors', e); - } finally { - this.isLoading = false; - } - }, - - async search() { - const extensionDataService = Shopware.Service('extensionStoreDataService'); - - const page = await extensionDataService.getExtensionList( - Shopware.Store.get('shopwareExtensions').search, - { ...Shopware.Context.api, languageId: Shopware.Store.get('session').languageId }, - ); - - Shopware.Store.get('shopwareExtensions').extensionListing = page; - }, - - setPage({ limit, page }) { - Shopware.Store.get('shopwareExtensions').setSearchValue({ key: 'limit', value: limit }); - Shopware.Store.get('shopwareExtensions').setSearchValue({ key: 'page', value: page }); - }, - }, -}; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/sw-extension-store-listing.html.twig b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/sw-extension-store-listing.html.twig deleted file mode 100644 index 27525907..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/sw-extension-store-listing.html.twig +++ /dev/null @@ -1,28 +0,0 @@ -{% block sw_extension_store_listing %} -
    - {% block sw_extension_store_listing_loader %} - - {% endblock %} - - - - - {% block sw_extension_store_listing_filter %} - - {% endblock %} - - {% block sw_extension_store_listing_grid %} -
    - -
    - {% endblock %} - - {% block sw_extension_store_listing_pagination %} - - {% endblock %} -
    -{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/sw-extension-store-listing.scss b/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/sw-extension-store-listing.scss deleted file mode 100644 index 23662f82..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/page/sw-extension-store-listing/sw-extension-store-listing.scss +++ /dev/null @@ -1,50 +0,0 @@ -@import "~scss/variables"; - -.sw-extension-store-listing { - margin: 0 auto; - max-width: 960px; - position: relative; - - .sw-extension-store-listing__banner { - margin-bottom: 40px; - cursor: pointer; - border-radius: 8px; - box-shadow: 0 0 4px 0 var(--color-elevation-shadow-default); - overflow: hidden; - display: flex; - - img { - width: 100%; - } - } - - .sw-extension-store-listing__listing-grid { - display: grid; - grid-gap: 24px; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - justify-content: space-between; - margin-bottom: 40px; - } - - .sw-pagination { - border: none; - background: none; - - .sw-pagination__per-page { - display: none; - } - } - - .sw-loader { - background: var(--color-elevation-surface-raised); - min-height: 300px; - - .sw-loader__container { - position: sticky; - } - } - - .sw-extension-store-statistics-promotion { - margin-bottom: 40px; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts new file mode 100644 index 00000000..f965f932 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.spec.ts @@ -0,0 +1,332 @@ +import { flushPromises } from '@vue/test-utils'; +import { handle, publish } from '@shopware-ag/meteor-admin-sdk/es/channel'; +import { ExtensionStoreChannelService } from 'SwagExtensionStore/module/sw-extension-store/service/extension-store-channel.service'; +import extensionStorePurchaseConfirmationStore from 'SwagExtensionStore/module/sw-extension-store/store/extension-store-purchase-confirmation.store'; +import type { ExtensionStoreBasket } from 'SwagExtensionStore/module/sw-extension-store/types/extension-store-basket.types'; + +jest.mock('@shopware-ag/meteor-admin-sdk/es/channel', () => ({ + handle: jest.fn(() => jest.fn()), + publish: jest.fn(), +})); + +jest.mock('SwagExtensionStore/util/telemetry', () => ({ + trackExtensionStoreEvent: jest.fn(() => Promise.resolve()), +})); + +const handleMock = handle as jest.MockedFunction; +const publishMock = publish as jest.MockedFunction; + +const buildCart = (type: 'app' | 'plugin' = 'plugin') => ({ + positions: [ + { + extension: { + id: 1, + name: 'SwagExtension', + type, + }, + variant: { id: 2, duration: 1 }, + netPrice: 19.99, + taxRate: 19, + firstMonthFree: false, + pseudoPrice: 19.99, + discountAppliesForMonths: null, + }, + ], +} as unknown as ExtensionStoreBasket); + +const purchaseActionData = { + action: 'purchase', + productId: 'product-id', + variantId: 'variant-id', + isCompatible: true, + sessionToken: 'session-token', +}; + +describe('SwagExtensionStore/module/sw-extension-store/service/extension-store-channel.service', () => { + let service: ExtensionStoreChannelService; + let extensionStoreActionService: { getMyExtensions: jest.Mock }; + let shopwareExtensionService: { checkLogin: jest.Mock; updateExtensionData: jest.Mock }; + let extensionStoreLicensesService: { newCart: jest.Mock; getPaymentMeans: jest.Mock; orderCart: jest.Mock }; + let extensionHelperService: { downloadAndActivateExtension: jest.Mock }; + let cacheApiService: { clear: jest.Mock }; + let extensionStorePreferencesService: { state: { installAfterPurchase: boolean } }; + let createNotificationSpy: jest.SpyInstance; + let router: { push: jest.Mock; afterEach: jest.Mock; currentRoute: { value: { params: object; query: object } } }; + + const createService = (cart: ExtensionStoreBasket) => { + extensionStoreActionService = { + getMyExtensions: jest.fn().mockResolvedValue([{ name: 'SwagExtension', version: '2.1.0' }]), + }; + shopwareExtensionService = { + checkLogin: jest.fn().mockResolvedValue(undefined), + updateExtensionData: jest.fn().mockResolvedValue(undefined), + }; + extensionStoreLicensesService = { + newCart: jest.fn().mockResolvedValue({ data: cart }), + getPaymentMeans: jest.fn().mockResolvedValue({ data: [] }), + orderCart: jest.fn().mockResolvedValue(undefined), + }; + extensionHelperService = { + downloadAndActivateExtension: jest.fn().mockResolvedValue(undefined), + }; + cacheApiService = { clear: jest.fn().mockResolvedValue(undefined) }; + extensionStorePreferencesService = { state: { installAfterPurchase: true } }; + router = { + push: jest.fn(), + afterEach: jest.fn(() => jest.fn()), + currentRoute: { value: { params: {}, query: {} } }, + }; + + return new ExtensionStoreChannelService( + extensionStoreActionService as never, + shopwareExtensionService as never, + extensionStoreLicensesService as never, + extensionHelperService as never, + cacheApiService as never, + router as never, + extensionStorePreferencesService as never, + ); + }; + + /** Runs the handshake through the channel and returns the context the iframe receives. */ + const performHandshake = async (bundles?: Record) => { + Shopware.Context.app.config.bundles = bundles as never; + service.register(); + + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise<{ isDemoShop: boolean }>; + + return channelHandler({ action: 'handshake', sessionToken: 'session-token', version: '1.0.0' }); + }; + + /** Runs the purchase action through the channel and returns the confirm callback result. */ + const performPurchase = async () => { + service.register(); + + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; + await channelHandler(purchaseActionData); + + const onConfirm = extensionStorePurchaseConfirmationStore().onConfirm; + expect(onConfirm).not.toBeNull(); + + return onConfirm!(); + }; + + beforeEach(() => { + handleMock.mockClear(); + publishMock.mockClear(); + extensionStorePurchaseConfirmationStore().$reset(); + createNotificationSpy = jest.spyOn(Shopware.Store.get('notification'), 'createNotification') + .mockImplementation(() => null); + service = createService(buildCart()); + }); + + afterEach(() => { + service.unregister(); + jest.restoreAllMocks(); + }); + + describe('handshake', () => { + it('should report a demo shop when the demo environment bundle is present', async () => { + const context = await performHandshake({ SwagDemoEnvironment: { js: [], css: [] } }); + + expect(context.isDemoShop).toBe(true); + }); + + it('should report a regular shop when the demo environment bundle is absent', async () => { + const context = await performHandshake({ SwagExtensionStore: { js: [], css: [] } }); + + expect(context.isDemoShop).toBe(false); + }); + + it('should report a regular shop when no bundles are known', async () => { + const context = await performHandshake(undefined); + + expect(context.isDemoShop).toBe(false); + }); + }); + + describe('routeTo', () => { + it('should navigate to the named route with its parameters', async () => { + service.register(); + + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; + await channelHandler({ + action: 'routeTo', + name: 'sw.extension.store.detail', + params: { id: 'extension-id' }, + }); + + expect(router.push).toHaveBeenCalledWith({ + name: 'sw.extension.store.detail', + params: { id: 'extension-id' }, + }); + }); + + it('should ignore route parameters with non-string values', async () => { + service.register(); + + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; + await channelHandler({ + action: 'routeTo', + name: 'sw.extension.store.detail', + params: { id: 1 }, + }); + + expect(router.push).not.toHaveBeenCalled(); + }); + }); + + describe('purchase with installation', () => { + it('should request a reload and publish the installed version', async () => { + const result = await performPurchase(); + + expect(extensionHelperService.downloadAndActivateExtension).toHaveBeenCalledWith('SwagExtension', 'plugin'); + expect(result).toEqual({ success: true, requiresReload: true }); + expect(publishMock).toHaveBeenCalledWith('swag-extension-store-channel', { + action: 'purchaseResult', + sessionToken: 'session-token', + success: true, + extensionVersion: '2.1.0', + }); + }); + + it('should publish the purchase result after the extension has been installed', async () => { + await performPurchase(); + + const installOrder = extensionHelperService.downloadAndActivateExtension.mock.invocationCallOrder[0]; + const publishOrder = publishMock.mock.invocationCallOrder[publishMock.mock.calls.length - 1]; + + expect(installOrder).toBeLessThan(publishOrder); + }); + + it('should notify about the successful installation', async () => { + await performPurchase(); + + expect(createNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + variant: 'positive', + growl: true, + })); + }); + + it.each(['plugin', 'app'] as const)('should clear the cache for a %s', async (type) => { + service = createService(buildCart(type)); + + await performPurchase(); + + expect(cacheApiService.clear).toHaveBeenCalledTimes(1); + }); + + it('should publish the successful result when the modal is closed while installing', async () => { + let finishInstallation = (): void => {}; + extensionHelperService.downloadAndActivateExtension.mockReturnValue(new Promise((resolve) => { + finishInstallation = () => resolve(undefined); + })); + + service.register(); + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; + await channelHandler(purchaseActionData); + + const store = extensionStorePurchaseConfirmationStore(); + const confirmed = store.confirm(); + await flushPromises(); + + // The user closes the modal while the extension is being installed. + store.cancel(); + + expect(publishMock).not.toHaveBeenCalled(); + + finishInstallation(); + await confirmed; + + expect(publishMock).toHaveBeenCalledTimes(1); + expect(publishMock).toHaveBeenCalledWith('swag-extension-store-channel', { + action: 'purchaseResult', + sessionToken: 'session-token', + success: true, + extensionVersion: '2.1.0', + }); + }); + + it('should still request a reload when clearing the cache fails', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + cacheApiService.clear.mockRejectedValue(new Error('cache clear failed')); + + const result = await performPurchase(); + + expect(result).toEqual({ success: true, requiresReload: true }); + expect(createNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + variant: 'positive', + })); + }); + }); + + describe('purchase with a failing installation', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + extensionHelperService.downloadAndActivateExtension.mockRejectedValue(new Error('install failed')); + }); + + it('should not request a reload and skip the version lookup', async () => { + const result = await performPurchase(); + + expect(result).toEqual({ success: true, requiresReload: false }); + expect(extensionStoreActionService.getMyExtensions).not.toHaveBeenCalled(); + expect(publishMock).toHaveBeenCalledWith('swag-extension-store-channel', { + action: 'purchaseResult', + sessionToken: 'session-token', + success: true, + extensionVersion: null, + }); + }); + + it('should notify about the failed installation', async () => { + await performPurchase(); + + expect(createNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + variant: 'critical', + growl: true, + })); + }); + }); + + describe('purchase without installation', () => { + it('should not request a reload when the installation preference is disabled', async () => { + extensionStorePreferencesService.state.installAfterPurchase = false; + + const result = await performPurchase(); + + expect(result).toEqual({ success: true, requiresReload: false }); + expect(extensionHelperService.downloadAndActivateExtension).not.toHaveBeenCalled(); + expect(cacheApiService.clear).not.toHaveBeenCalled(); + }); + + it('should not request a reload when the extension is incompatible', async () => { + service.register(); + const channelHandler = handleMock.mock.calls[0][1] as (data: unknown) => Promise; + await channelHandler({ ...purchaseActionData, isCompatible: false }); + + const result = await extensionStorePurchaseConfirmationStore().onConfirm!(); + + expect(result).toEqual({ success: true, requiresReload: false }); + expect(extensionHelperService.downloadAndActivateExtension).not.toHaveBeenCalled(); + }); + }); + + describe('failing purchase', () => { + it('should not request a reload when the order fails', async () => { + extensionStoreLicensesService.orderCart.mockRejectedValue({ + response: { data: { errors: [{ title: 'order-error', description: 'order failed' }] } }, + }); + + const result = await performPurchase(); + + expect(result).toEqual({ + success: false, + title: 'order-error', + description: 'order failed', + documentationLink: '', + }); + expect(extensionHelperService.downloadAndActivateExtension).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts new file mode 100644 index 00000000..aac62fc1 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-channel.service.ts @@ -0,0 +1,602 @@ +import { handle, publish } from '@shopware-ag/meteor-admin-sdk/es/channel'; +import type ExtensionStoreActionService from 'src/module/sw-extension/service/extension-store-action.service'; +import type ShopwareExtensionService from 'src/module/sw-extension/service/shopware-extension.service'; +import type ExtensionStoreLicensesService from './extension-store-licenses.service'; +import type { LocationQuery, RouteLocationNormalizedGeneric, RouteLocationNormalizedLoadedGeneric, Router } from 'vue-router'; +import type { ShopwareMessageTypes } from '@shopware-ag/meteor-admin-sdk/es/message-types'; +import extensionStorePurchaseConfirmationStore, { type PurchaseConfirmationOnConfirmCallbackResult } from '../store/extension-store-purchase-confirmation.store'; +import type ExtensionHelperService from 'src/app/service/extension-helper.service'; +import type CacheApiService from 'src/core/service/api/cache.api.service'; +import type { ExtensionStoreBasket, ExtensionStorePaymentMean } from '../types/extension-store-basket.types'; +import extensionStoreContextStore + from 'SwagExtensionStore/module/sw-extension-store/store/extension-store-context.store'; +import { trackExtensionStoreEvent } from 'SwagExtensionStore/util/telemetry'; +import type { TrackableType } from 'src/core/telemetry/types'; +import type ExtensionStorePreferencesService + from 'SwagExtensionStore/module/sw-extension-store/service/extension-store-preferences.service'; +import type { UserInfo } from 'src/core/service/api/store.api.service'; +import { hasDemoShopBundle } from 'SwagExtensionStore/util/demo-shop'; + +type StoreChannelAction = 'handshake' | 'routeTo' | 'purchase' | 'routerUpdate' | 'copyToClipboard' | 'trackEvent'; + +type StoreChannelActionData = { + action: StoreChannelAction; +}; + +type HandshakeActionData = StoreChannelActionData & { + sessionToken: string; + version: string; +}; + +type RouteToActionData = StoreChannelActionData & { + name: string; + params?: Record; +}; + +type RouterUpdateActionData = StoreChannelActionData & { + urlSegments: string[]; + from: string | null; + to: string | null; + query: string | null; + listingQuery: Record; + queryProperties: string[]; + replaced: boolean; +}; + +type RouterSyncData = { + action: 'routerSync'; + currentRoute: string; + currentRouteQuery: LocationQuery; +}; + +type PurchaseActionData = StoreChannelActionData & { + productId: string; + variantId: string; + isCompatible: boolean; + sessionToken: string; +}; + +type StoreContextOwnedExtension = { + name: string; + version: string | null; +}; + +type StoreContext = { + shopwareVersion: string; + owningExtensions: StoreContextOwnedExtension[]; + sessionToken: string; + language: string; + licenseHost: string | null; + userInfo: UserInfo | null; + isDemoShop: boolean; + success: boolean; + currentRoute: string; + currentRouteQuery: LocationQuery; + navigateToExtension?: string | number; +}; + +type PurchaseResponse = { + sessionToken: string; + success: boolean; + +}; + +type PurchaseResultData = { + action: 'purchaseResult'; + sessionToken: string; + success: boolean; + extensionVersion?: string | null; +}; + +type CopyToClipboardActionData = StoreChannelActionData & { + text: string; +}; + +type TrackActionData = StoreChannelActionData & { + eventName: string; + [key: string]: TrackableType; +}; + +type StoreApiErrorItem = { + title?: string; + description?: string; + meta?: { + documentationLink?: string; + }; +}; + +type StoreApiErrorResponse = { + response?: { + data?: { + errors?: StoreApiErrorItem[]; + }; + }; +}; + +export class ExtensionStoreChannelService { + private removeRouterAfterEachHook?: () => void; + private unsubscribeFunction?: () => void; + private registeredAt?: number; + + constructor( + private readonly extensionStoreActionService: ExtensionStoreActionService, + private readonly shopwareExtensionService: ShopwareExtensionService, + private readonly extensionStoreLicensesService: ExtensionStoreLicensesService, + private readonly extensionHelperService: ExtensionHelperService, + private readonly cacheApiService: CacheApiService, + private readonly router: Router, + private readonly extensionStorePreferencesService: ExtensionStorePreferencesService, + ) { + } + + register(): void { + if (this.unsubscribeFunction) { + return; + } + + this.registeredAt = performance.now(); + + this.unsubscribeFunction = handle('swag-extension-store-channel' as keyof ShopwareMessageTypes, (data: unknown) => { + try { + return this.handleAction(data); + } catch (err) { + return { + success: false, + }; + } + }); + + this.removeRouterAfterEachHook = this.router.afterEach((to, from) => this.publishReturnToExtensionStore(to, from)); + + window.addEventListener('popstate', () => this.publishRouterSync()); + } + + unregister(): void { + extensionStoreContextStore().resetSkyBridgeStoreVersion(); + + this.unsubscribeFunction?.(); + this.unsubscribeFunction = undefined; + + this.removeRouterAfterEachHook?.(); + this.removeRouterAfterEachHook = undefined; + + window.removeEventListener('popstate', () => this.publishRouterSync()); + + this.registeredAt = undefined; + } + + private publishReturnToExtensionStore(to: RouteLocationNormalizedGeneric, from: RouteLocationNormalizedLoadedGeneric): void { + const isFromExtensionStore = from.name?.toString() === 'sw.extension.store'; + const isToExtensionStore = to.name?.toString() === 'sw.extension.store'; + const isFullPathEqual = from.fullPath === to.fullPath; + + if (!isFromExtensionStore || !isToExtensionStore || isFullPathEqual) { + return; + } + + this.publishRouterSync(); + } + + private handleAction(data: unknown): Promise | void { + if (!this.isStoreChannelActionData(data)) { + return; + } + switch (data.action) { + case 'handshake': + if (!this.isHandshakeActionData(data)) { + return; + } + return this.handleHandshake(data); + case 'routeTo': + if (!this.isRouteToActionData(data)) { + return; + } + return this.handleRouteTo(data); + case 'purchase': + if (!this.isPurchaseActionData(data)) { + return; + } + return this.handlePurchase(data); + case 'routerUpdate': + if (!this.isRouterUpdateActionData(data)) { + return; + } + return this.handleRouterUpdate(data); + case 'copyToClipboard': + if ((!this.isCopyToClipboardActionData(data))) { + return; + } + return this.handleCopyToClipboard(data); + case 'trackEvent': + if (!this.isTrackActionData(data)) { + return; + } + return this.handleTrack(data); + } + } + + private isStoreChannelActionData(data: unknown): data is StoreChannelActionData { + return ( + data !== null + && typeof data === 'object' + && 'action' in data + && typeof data.action === 'string' + ); + } + + private isHandshakeActionData(data: unknown): data is HandshakeActionData { + return ( + this.isStoreChannelActionData(data) + && 'sessionToken' in data + && 'version' in data + && typeof data.sessionToken === 'string' + && typeof data.version === 'string' + ); + } + + private isRouteToActionData(data: unknown): data is RouteToActionData { + return ( + this.isStoreChannelActionData(data) + && 'name' in data + && typeof data.name === 'string' + && (!('params' in data) || this.isRouteParams(data.params)) + ); + } + + private isRouteParams(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.values(value).every((parameter) => typeof parameter === 'string'); + } + + private isCopyToClipboardActionData(data: unknown): data is CopyToClipboardActionData { + return ( + this.isStoreChannelActionData(data) + && 'text' in data + && typeof data.text === 'string' + ); + } + + private isPurchaseActionData(data: unknown): data is PurchaseActionData { + return ( + this.isStoreChannelActionData(data) + && 'productId' in data + && 'variantId' in data + && 'isCompatible' in data + && 'sessionToken' in data + && typeof data.productId === 'string' + && typeof data.variantId === 'string' + && typeof data.isCompatible === 'boolean' + && typeof data.sessionToken === 'string' + ); + } + + private isListingQueryValue(value: unknown): value is string | string[] | null | undefined { + return value === null + || value === undefined + || typeof value === 'string' + || (Array.isArray(value) && value.every((item: unknown) => typeof item === 'string')); + } + + private isListingQuery(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.values(value).every((entry: unknown) => this.isListingQueryValue(entry)); + } + + private isRouterUpdateActionData(data: unknown): data is RouterUpdateActionData { + return ( + this.isStoreChannelActionData(data) && 'urlSegments' in data + && 'from' in data + && 'to' in data + && 'query' in data + && 'listingQuery' in data + && 'queryProperties' in data + && 'replaced' in data + && Array.isArray((data as { urlSegments: unknown }).urlSegments) + && (data as { urlSegments: unknown[] }).urlSegments.every((segment: unknown) => typeof segment === 'string') + && this.isListingQuery((data as { listingQuery: unknown }).listingQuery) + && Array.isArray((data as { queryProperties: unknown }).queryProperties) + && (data as { queryProperties: unknown[] }).queryProperties.every((property: unknown) => typeof property === 'string') + ); + } + + private isTrackActionData(data: unknown): data is TrackActionData { + return ( + this.isStoreChannelActionData(data) + && 'eventName' in data + && typeof data.eventName === 'string' + ); + } + + private async handleHandshake(data: HandshakeActionData): Promise { + const extensions = await this.extensionStoreActionService.getMyExtensions(); + const owningExtensions = extensions.filter(extension => !!extension.storeLicense) + .map((extension) => ({ + name: extension.name, + version: extension.version ?? null, + })); + + await this.shopwareExtensionService.checkLogin(); + + const contextStore = extensionStoreContextStore(); + const licenseHost = await contextStore.loadLicenseHost(); + const shopwareVersion = Shopware.Context.app.config.version ?? ''; + const rawLocale: unknown = Shopware.Store.get('session')?.currentLocale; + const language: string = typeof rawLocale === 'string' ? rawLocale : 'en-GB'; + const userInfo = Shopware.Store.get('shopwareExtensions').userInfo; + const currentRoute = this.getCurrentRoute(); + const currentRouteQuery = this.getCurrentRouteQuery(); + + extensionStoreContextStore().updateSkyBridgeStoreVersion(data.version); + trackExtensionStoreEvent('extension_store_connected', { + loading_duration_ms: this.registeredAt ? performance.now() - this.registeredAt : -1, + }); + + return { + shopwareVersion: shopwareVersion, + owningExtensions: owningExtensions, + sessionToken: data.sessionToken, + language: language, + licenseHost, + userInfo: userInfo, + isDemoShop: hasDemoShopBundle(), + success: true, + currentRoute: currentRoute, + currentRouteQuery: currentRouteQuery, + navigateToExtension: this.getNavigateToExtension(), + }; + } + + private handleRouteTo(data: RouteToActionData): void { + this.router.push({ name: data.name, params: data.params }); + } + + private async handlePurchase(data: PurchaseActionData): Promise { + try { + const [cart, paymentMeans] = await Promise.all([ + (await this.extensionStoreLicensesService.newCart( + data.productId, + data.variantId, + )).data as ExtensionStoreBasket, + (await this.extensionStoreLicensesService.getPaymentMeans()).data as ExtensionStorePaymentMean[], + ]); + + extensionStorePurchaseConfirmationStore().openModal({ + cart, + paymentMeans, + isCompatible: data.isCompatible, + onConfirm: () => this.performPurchase(data, cart), + onCancel: () => this.publishPurchaseResult(data.sessionToken, false), + }); + } catch (error) { + const errorDetails = this.getErrorDetails(error); + extensionStorePurchaseConfirmationStore().openErrorModal(errorDetails.title, errorDetails.description, errorDetails.documentationLink, () => { + this.publishPurchaseResult(data.sessionToken, false); + }); + } + + // Respond immediately to the iframe so the channel does not time out. + // The final purchase result is communicated via a separate published message. + return { + sessionToken: data.sessionToken, + success: true, + }; + } + + private publishPurchaseResult( + sessionToken: string, + success: boolean, + extensionVersion?: string | null, + ): void { + const resultData: PurchaseResultData = { + action: 'purchaseResult', + sessionToken, + success, + ...(extensionVersion !== undefined ? { extensionVersion } : {}), + }; + + publish( + 'swag-extension-store-channel' as keyof ShopwareMessageTypes, + resultData, + ); + } + + private publishRouterSync(): void { + const currentRoute = this.getCurrentRoute(); + const currentRouteQuery = this.getCurrentRouteQuery(); + const data: RouterSyncData = { + action: 'routerSync', + currentRoute, + currentRouteQuery, + }; + + publish( + 'swag-extension-store-channel' as keyof ShopwareMessageTypes, + data, + ); + } + + private handleRouterUpdate(data: RouterUpdateActionData): void { + const current = this.router.currentRoute.value; + const currentPath = current.path; + let newPath = current.path; + + if (current.params?.pathMatch?.length > 0) { + newPath = newPath + .split('/') + .filter(segment => !current.params.pathMatch.includes(segment)) + .join('/'); + } + + if (data.urlSegments.length > 0) { + newPath += `/${data.urlSegments.join('/')}`; + } + + const newQuery: LocationQuery = { ...current.query }; + + for (const key of data.queryProperties) { + const listingQueryValue = data.listingQuery[key]; + if (listingQueryValue !== undefined && listingQueryValue !== null) { + newQuery[key] = listingQueryValue; + } else { + delete newQuery[key]; + } + } + + this.router.push({ + path: newPath, + query: newQuery, + hash: current.hash, + replace: data.replaced, + }).then(() => { + if (newPath === currentPath) { + return; + } + + // Currently, this is firing a separate page_viewed event to track our own route names & add our own source. + // In the background, the Admin itself tracks every route change, so it's essentially duplicated. + trackExtensionStoreEvent('page_viewed', { + sw_route_from_href: currentPath, + sw_route_to_href: newPath, + ...(data.from ? { sw_route_from_name: `sw.extension.store.${data.from}` } : {}), + ...(data.to ? { sw_route_to_name: `sw.extension.store.${data.to}` } : {}), + ...(data.query ? { sw_route_to_query: data.query } : {}), + }); + }); + } + + private handleCopyToClipboard(data: StoreChannelActionData & { text: string }): void { + navigator.clipboard.writeText(data.text).catch((err) => { + console.error('Failed to copy text to clipboard', err); + }); + } + + private handleTrack({ action: _, eventName, ...data }: TrackActionData): void { + trackExtensionStoreEvent(eventName, data); + } + + private async performPurchase(data: PurchaseActionData, cartData: ExtensionStoreBasket): Promise { + extensionStorePurchaseConfirmationStore().checkoutStep = 'order'; + + try { + await this.extensionStoreLicensesService.orderCart(cartData); + } catch (e) { + const errorDetails = this.getErrorDetails(e); + this.publishPurchaseResult(data.sessionToken, false); + return { success: false, ...errorDetails }; + } + + extensionStorePurchaseConfirmationStore().checkoutStep = 'update'; + await this.updateExtensionData(); + + let isInstalled = false; + if (data.isCompatible && this.extensionStorePreferencesService.state.installAfterPurchase) { + extensionStorePurchaseConfirmationStore().checkoutStep = 'install'; + isInstalled = await this.installExtension(cartData); + } + + let extensionVersion: string | null = null; + if (isInstalled) { + extensionStorePurchaseConfirmationStore().checkoutStep = 'reload'; + extensionVersion = await this.getInstalledExtensionVersion(cartData.positions[0].extension.name); + } + + this.publishPurchaseResult(data.sessionToken, true, extensionVersion); + + return { success: true, requiresReload: isInstalled }; + } + + private async getInstalledExtensionVersion(extensionName: string): Promise { + try { + const extensions = await this.extensionStoreActionService.getMyExtensions(); + + return extensions.find((extension) => extension.name === extensionName)?.version ?? null; + } catch (error) { + console.error('Failed to get installed extension version after purchase', error); + + return null; + } + } + + private getCurrentRoute(): string { + return Object.values(this.router.currentRoute.value.params.pathMatch || {}).join('/'); + } + + private getCurrentRouteQuery(): LocationQuery { + return this.router.currentRoute.value.query || {}; + } + + private getErrorDetails(error: unknown): { title: string; description: string; documentationLink: string } { + const maybeError = error as StoreApiErrorResponse; + const errorDetails = maybeError.response?.data?.errors?.at(-1); + + return { + title: errorDetails?.title ?? '', + description: errorDetails?.description ?? '', + documentationLink: errorDetails?.meta?.documentationLink ?? '', + }; + } + + private getNavigateToExtension(): string | number | undefined { + const state = window.history.state as unknown; + + return state && typeof state === 'object' && 'extensionIdentifier' in state + ? state.extensionIdentifier as string | number + : undefined; + } + + private async updateExtensionData(): Promise { + try { + await this.shopwareExtensionService.updateExtensionData(); + } catch (error) { + console.error('Failed to update extension data', error); + } + } + + /** + * A failing cache clear must not be reported as a failed installation, the extension is + * installed at this point either way and the following reload picks it up. + */ + private async clearCache(): Promise { + try { + await this.cacheApiService.clear(); + } catch (error) { + console.error('Failed to clear the cache after installing an extension', error); + } + } + + private async installExtension(cartData: ExtensionStoreBasket): Promise { + const extension = cartData.positions[0].extension; + const snippetService = Shopware.Snippet as unknown as { + tc: (key: string, params?: Record) => string; + }; + + try { + await this.extensionHelperService.downloadAndActivateExtension(extension.name, extension.type); + await this.clearCache(); + + Shopware.Store.get('notification').createNotification({ + variant: 'positive', + title: snippetService.tc('sw-extension-store.installation.successTitle'), + message: snippetService.tc('sw-extension-store.installation.successMessage', { name: String(extension.name) }), + growl: true, + }); + + return true; + } catch (error) { + console.error('Failed to install extension after purchase', error); + + Shopware.Store.get('notification').createNotification({ + variant: 'critical', + title: snippetService.tc('sw-extension-store.installation.errorTitle'), + message: snippetService.tc('sw-extension-store.installation.errorMessage', { name: String(extension.name) }), + growl: true, + }); + + return false; + } + } +} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-data.service.js b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-data.service.js deleted file mode 100644 index f641d09c..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-data.service.js +++ /dev/null @@ -1,123 +0,0 @@ -const { Criteria } = Shopware.Data; - -export default class ExtensionStoreDataService extends Shopware.Classes.ApiService { - constructor(httpClient, loginService, apiEndpoint = 'extension-store') { - super(httpClient, loginService, apiEndpoint); - this.name = 'extensionStoreDataService'; - } - - async listingFilters(context) { - const res = await this.httpClient.get(`_action/${this.apiEndpoint}/store-filters`, { - headers: this.basicHeaders(context), - version: 3, - }); - - return res.data; - } - - async getExtensionList(search, context) { - const criteria = this._getCriteriaFromSearch(search); - - const { data } = await this.httpClient.post(`_action/${this.apiEndpoint}/list`, criteria.parse(), { - headers: this.basicHeaders(context), - version: 3, - }); - - const extensions = []; - extensions.total = data.meta.total; - extensions.push(...data.data); - - return extensions; - } - - /** - * @returns {Promise} - */ - async getExtensionByName(name, context) { - return this.getExtensionList({ term: name }, context).then( - (extensions) => extensions.find((extension) => extension.name === name) ?? null, - ); - } - - async getDetail(id, context) { - const { data } = await this.httpClient.get(`_action/${this.apiEndpoint}/detail/${id}`, { - headers: this.basicHeaders(context), - version: 3, - }); - - return data; - } - - async getReviews(page, limit, id) { - const criteria = new Criteria( - page, - limit, - ); - - const { data } = await this.httpClient.get(`_action/${this.apiEndpoint}/${id}/reviews`, { - headers: this.basicHeaders(), - params: criteria.parse(), - version: 3, - }); - - return data; - } - - basicHeaders(context = null) { - const headers = { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${this.loginService.getToken()}`, - }; - - if (context && context.languageId) { - headers['sw-language-id'] = context.languageId; - } - - return headers; - } - - _getCriteriaFromSearch({ - page = 1, - limit = 25, - rating = null, - category = null, - term = null, - sorting = null, - filter = {}, - } = {}) { - const criteria = new Criteria( - page, - limit, - ); - - if (term) { - criteria.setTerm(term); - } - - const filters = []; - - if (rating !== null) { - filters.push(Criteria.equals('rating', rating)); - } - - Object.entries(filter).forEach(([field, value]) => { - filters.push(Criteria.equals(field, value)); - }); - - if (category !== null) { - filters.push(Criteria.equals('category', category)); - } - - if (filters.length > 0) { - criteria.addFilter(Criteria.multi('AND', filters)); - } - - if (sorting) { - criteria.resetSorting(); - criteria.addSorting(sorting); - } - - return criteria; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-preferences.service.ts b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-preferences.service.ts new file mode 100644 index 00000000..d1a09c3a --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store-preferences.service.ts @@ -0,0 +1,74 @@ +import { reactive } from 'vue'; +import type UserConfigService from 'src/core/service/api/user-config.api.service'; + +export interface ExtensionStorePreferences { + installAfterPurchase: boolean; +} + +const DEFAULTS: ExtensionStorePreferences = { + installAfterPurchase: true, +}; + +class ExtensionStorePreferencesService { + static USER_CONFIG_KEY = 'SwagExtensionStore.preferences'; + + private _state = reactive({ ...DEFAULTS }); + + private loadPromise: Promise | null = null; + + constructor(private readonly userConfigService: UserConfigService) { + void this.load(); + } + + get state(): Readonly { + return this._state; + } + + load(): Promise { + if (this.loadPromise) { + return this.loadPromise; + } + + this.loadPromise = this.userConfigService + .search([ExtensionStorePreferencesService.USER_CONFIG_KEY]) + .then((response) => { + if (!response) { + return; + } + + const config = response.data[ExtensionStorePreferencesService.USER_CONFIG_KEY]; + if (!config) { + return; + } + + const [stored] = config as [Partial | undefined]; + if (!stored) { + return; + } + + Object.assign(this._state, stored); + }) + .catch(() => { + // Reset in case the API call failed. + this.loadPromise = null; + }); + + return this.loadPromise; + } + + async update(partial: Partial): Promise { + const previous = { ...this._state }; + + Object.assign(this._state, partial); + + try { + await this.userConfigService.upsert({ + [ExtensionStorePreferencesService.USER_CONFIG_KEY]: [{ ...this._state }], + }); + } catch { + Object.assign(this._state, previous); + } + } +} + +export default ExtensionStorePreferencesService; diff --git a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store.service.ts b/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store.service.ts deleted file mode 100644 index 020afdec..00000000 --- a/src/Resources/app/administration/src/module/sw-extension-store/service/extension-store.service.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { ShopwareDiscountCampaignService } from 'src/app/service/discount-campaign.service'; -import type { ExtensionVariant } from 'src/module/sw-extension/service/extension-store-action.service'; -import type ShopwareExtensionService from 'src/module/sw-extension/service/shopware-extension.service'; - -const { Utils } = Shopware; - -export default class ExtensionStoreService { - public readonly EXTENSION_RENT_DURATIONS = { - MONTHLY: 1, - YEARLY: 12, - }; - - constructor( - private readonly discountCampaignService: ShopwareDiscountCampaignService, - private readonly extensionService: ShopwareExtensionService, - ) { - } - - public orderVariantsByPricePerMonth(variants: ExtensionVariant[]): ExtensionVariant[] { - return variants.toSorted((first, second) => { - const firstPrice = this.getPriceFromVariant(first, true); - const secondPrice = this.getPriceFromVariant(second, true); - - return firstPrice - secondPrice; - }); - } - - public orderVariantsByRentDuration(variants: ExtensionVariant[]): ExtensionVariant[] { - if (variants.length === 1) { - return variants; - } - - return variants.toSorted((first, second) => { - if (!first.duration || !second.duration) { - return 0; - } - - return first.duration - second.duration; - }); - } - - public getCalculatedPrice(variant: ExtensionVariant): string { - const perMonth = this.isVariantOfTypeRent(variant); - const price = this.getPriceFromVariant(variant, perMonth); - - return Utils.format.currency(price, 'EUR', 2); - } - - public getCalculatedPriceSnippet(variants: ExtensionVariant[]): string { - if (variants.length > 1) { - return 'sw-extension-store.general.labelFromPricePerMonth'; - } - - const recommendedVariant = this.getRecommendedVariant(variants); - - return this.getPriceSnippetForVariant(recommendedVariant); - } - - public getPriceSnippetForVariant(variant: ExtensionVariant): string { - if (this.isVariantOfTypeBuy(variant)) { - return 'sw-extension-store.general.labelPriceOneTime'; - } - - return 'sw-extension-store.general.labelPricePerMonth'; - } - - public getPriceFromVariant(variant: ExtensionVariant, perMonth = false): number { - if (this.isVariantDiscounted(variant)) { - return perMonth ? variant?.discountCampaign.discountedPricePerMonth : variant?.discountCampaign.discountedPrice; - } - - let price = 0; - - if (perMonth && (variant as ExtensionVariant & { netPricePerMonth: number })?.netPricePerMonth) { - price = (variant as ExtensionVariant & { netPricePerMonth: number }).netPricePerMonth; - } else if (variant?.netPrice) { - price = variant.netPrice; - } - - return price; - } - - public getRecommendedVariant(variants: ExtensionVariant[]): ExtensionVariant { - if (variants.length === 1) { - return variants[0]; - } - - return this.orderVariantsByPricePerMonth(variants)[0]; - } - - public isExtensionDiscounted(variants: ExtensionVariant[]): boolean { - return variants.some((variant) => this.isVariantDiscounted(variant)); - } - - public isVariantDiscounted(variant: ExtensionVariant): variant is ExtensionVariant & { - discountCampaign: { - discountedPrice: number; - discountedPricePerMonth: number; - }; - } { - if ( - !variant || - !variant.discountCampaign || - typeof variant.discountCampaign.discountedPrice !== 'number' || - variant.discountCampaign.discountedPrice === variant.netPrice - ) { - return false; - } - - return this.discountCampaignService.isDiscountCampaignActive(variant.discountCampaign); - } - - public isVariantOfTypeBuy(variant: ExtensionVariant): boolean { - return variant?.type === this.extensionService.EXTENSION_VARIANT_TYPES.BUY; - } - - public isVariantOfTypeFree(variant: ExtensionVariant): boolean { - return variant?.type === this.extensionService.EXTENSION_VARIANT_TYPES.FREE; - } - - public isVariantOfTypeRent(variant: ExtensionVariant): boolean { - return variant?.type === this.extensionService.EXTENSION_VARIANT_TYPES.RENT; - } - - public isRentDurationMonthly(variant: ExtensionVariant): boolean { - return variant?.duration === this.EXTENSION_RENT_DURATIONS.MONTHLY; - } - - public isRentDurationYearly(variant: ExtensionVariant): boolean { - return variant?.duration === this.EXTENSION_RENT_DURATIONS.YEARLY; - } -} diff --git a/src/Resources/app/administration/src/module/sw-extension-store/snippet/de.json b/src/Resources/app/administration/src/module/sw-extension-store/snippet/de.json index e0c33fe7..c2e04c69 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/snippet/de.json +++ b/src/Resources/app/administration/src/module/sw-extension-store/snippet/de.json @@ -1,105 +1,136 @@ { "sw-extension-store": { + "component": { + "sw-extension-permissions-details-modal": { + "operationUpdate": "Bearbeiten", + "operationDelete": "Löschen" + } + }, "general": { - "labelPriceFirstMonth": "{price} / 1. Monat", - "labelPriceOneTime": "{price}", - "labelPricePerMonth": "{price} /Monat", - "labelPricePerYear": "{price} /Jahr", - "labelFromPricePerMonth": "ab {price} /Monat", - "labelFree": "Kostenlos", - "labelSale": "% Sale", - "labelSavings": "{percentage}% Rabatt", - "labelDiscountedPrice": "Kostenlos | {price} {discountedPrice}/Monat | {price} {discountedPrice}", "title": "Store", "taxNotice": "* Alle Preise verstehen sich zzgl. Mehrwertsteuer" }, - "tabs": { - "apps": "Apps", - "themes": "Themes" - }, - "listing": { - "placeholderSearchBar": "Finde Erweiterungen ..." - }, - "updateWarning": { - "headline": "Wir haben den Shopware Store aktualisiert.
    Nimm Dir einen Moment Zeit, um auf die neueste Version zu aktualisieren.", - "description": "Entdecke zahlreiche, großartige Erweiterungen, die Dir helfen, Dein Geschäft auszubauen.", - "requestDescription": "Du musst auf die neueste Version upgraden, um neue und aktualisierte Erweiterungen oder Themes für Deinen Shop zu erhalten.", - "update": "Update" - }, - "offline": { - "headline": "Der Shopware Store ist nicht verfügbar", - "description": "Überprüfe Deine Internetverbindung oder versuche es später noch einmal." - }, - "detail": { - "enterpriseContactLinkText": "Vertrieb kontaktieren", - "enterpriseContactUrl": "https://www.shopware.com/de/enterprise-kontakt", - "enterpriseFeatureAlertText": "Diese Erweiterung ist Bestandteil der Enterprise Edition.", - "hasTrialPhase": "Probemonat inbegriffen", - "openConfiguration": "Konfiguration", - "themeIsAlreadyInstalled": "Theme ist bereits installiert", - "extensionIsAlreadyInstalled": "Erweiterung ist bereits installiert", - "inAppPurchases": { - "label": "In-App Einkäufe", - "paymentOptions": "Zahlungsoptionen" - } + "in-app-purchases": { + "label": "In-App Einkäufe", + "paymentOptions": "Zahlungsoptionen", + "onceOff": "einmalig" }, - "typeLabels": { - "self-hosted": "Selbst-gehosted", - "cloud": "Cloud" + "legacyServices": { + "description": "Diese Erweiterung ist jetzt als Shopware Service verfügbar.", + "openServices": "Shopware Payments öffnen" }, - "buy-modal": { - "paymentSelectionPlaceholder": "Wähle eine Zahlungsmethode ...", - "checkboxes": { - "permissionsAndAppProvider": { - "introText": "Ich bin mir bewusst, dass diese App weitere", - "buttonPermissions": "Berechtigungen", - "middleText": "benötigt und akzeptiere den", - "buttonAppProvider": "App-Anbieter", - "endText": "als neuen Unterauftragnehmer von Shopware", - "appProviderStartText": "Ich akzeptiere den" + "purchase-confirmation": { + "backToCheckout": "Zurück zur Kasse", + "closeWindow": "Fenster schließen", + "modalTitle": "Kauf bestätigen", + "checkout": { + "success": { + "title": "Dein Kauf war erfolgreich!", + "description": "Eine Bestätigungsemail wurde an dich gesendet. Die Erweiterung ist nun in deinem Shopware Account und im Admin deines Shops sichtbar." + }, + "failed": { + "title": "Etwas ist schiefgelaufen", + "description": "Entschuldigung, dein Kauf konnte nicht abgeschlossen werden.", + "documentationLink": "Siehe Dokumentation" + }, + "overview": { + "billingAddress": "Rechnungsadresse", + "bookingDomain": "Buchungsdomain", + "by": "von", + "change": "ändern", + "changeBillingAddressUrl": "https://account.shopware.com/settings/data", + "paymentMethod": "Zahlungsmethode", + "noPaymentMethod": "Keine Zahlungsmethode gespeichert", + "addPaymentMethod": "Zahlungsmethode hinzufügen", + "paymentMethodBanner": { + "noPaymentMethod": { + "title": "Bitte füge eine Zahlungsmethode hinzu, um fortzufahren", + "description": "Um Erweiterungen in unserem Store zu kaufen, ist eine Zahlungsmethode erforderlich. Bitte füge eine Zahlungsmethode in deinem Shopware Account hinzu. Um sie hinzuzufügen, klicke auf den Button unten.", + "action": "Zahlungsmethode hinzufügen" + }, + "change": { + "title": "Zahlungsmethode ändern?", + "description": "Die standardmäßige Zahlungsmethode, die in deinem Shopware Account gespeichert ist, wird für diesen Kauf verwendet. Um sie zu ändern, klicke auf den Button unten.

    Hinweis: Nach dem Aktualisieren der standardmäßigen Zahlungsmethode in deinem Shopware Account wird sie im Hintergrund aktualisiert und automatisch verwendet, wenn du diese Bestellung absendest. Kein Neuladen erforderlich.", + "action": "Zahlungsmethode ändern" + } + } + }, + "demo": { + "hint": { + "title": "Test-Lizenz für Demo-Umgebung", + "description": "Du buchst diese Erweiterung als Test-Lizenz für deine Demo-Umgebung. Diese Lizenz ist ausschließlich für Testzwecke bestimmt und kann nicht in einer Produktivumgebung verwendet werden. In der Demo-Testphase entstehen keine Kosten." + }, + "install": { + "label": "Erweiterung nach Erstellung der Test-Lizenz automatisch installieren" + }, + "order": { + "submit": "Test-Lizenz erstellen", + "submit-and-install": "Test-Lizenz erstellen & installieren" + } + }, + "install": { + "title": "Installation", + "label": "Erweiterung nach dem Kauf automatisch installieren", + "hint": "Die Erweiterung ist nicht als kompatibel mit deiner Shop-Version gekennzeichnet." + }, + "toc": { + "title": "Nutzungsbedingungen", + "checkboxes": { + "gtc": { + "text": "Ich habe die {link} gelesen und stimme ihnen zu.", + "link": "Allgemeine Nutzungsbedingungen" + }, + "permissions": { + "text": "Ich bin mir bewusst, dass diese Erweiterung weitere Berechtigungen benötigt.", + "link": "Berechtigungen anzeigen" + } + } + }, + "order": { + "firstMonth": "/1. Monat", + "followingPerMonth": "Anschließend {price}** /Monat", + "followingPerYear": "Anschließend {price}** /Jahr", + "forMonths": "für {months} Monate", + "forFirstYear": "für das erste Jahr", + "perMonth": "/Monat", + "perYear": "/Jahr", + "submit": "Bestellung absenden", + "submit-and-install": "Bestellung absenden und installieren", + "then": "Dann" + }, + "notes": { + "tax": "* Alle Preise verstehen sich ohne die gesetzliche Mehrwertsteuer" + }, + "loading": { + "title": { + "order": "Deine Bestellung wird verarbeitet ...", + "update": "Deine Erweiterungsdaten werden aktualisiert ...", + "install": "Deine Erweiterung wird installiert ...", + "reload": "Dein Shop wird neu geladen ..." + }, + "description": "Bitte warten, dies kann einige Sekunden dauern." } }, - "legalTextModal": { - "title": "Unterauftragnehmer von Shopware" - }, - "rent": { - "priceDisplay": "{priceDisplay}/Monat", - "freeTrial": "30-Tage-Testversion", - "disclaimer": "Es können zusätzliche Kosten anfallen.", - "followingDiscount": "anschließend {price} /Monat für {months} Monate,", - "monthly": { - "discountInformation": "Für {months} Monate, dann {price} /Monat", - "cancellationInformation": "Monatlich kündbar", - "finalPrice": "dann {price} /Monat" + "permissions": { + "intro": { + "label": "Berechtigungen", + "descriptionPermissions": "{extensionLabel} wird Zugriff auf die Komponenten unten erhalten.", + "descriptionDomains": "Einige Daten werden auch mit den Domains unten geteilt." }, - "yearly": { - "discountInformation": "Für 12 Monate, dann {price} /Jahr", - "cancellationInformation": "Jährlich kündbar", - "finalPrice": "dann {price} /Jahr" + "domains": { + "label": "Domains", + "description": "Einige Daten werden auch mit den folgenden Domains geteilt." } - }, - "warnings": { - "paymentMeansRequiredText": "Um diese App zu kaufen, musst Du", - "paymentMeansRequiredLinkText": "eine Zahlungsmethode hinzufügen" - }, - "plusVat": "* Alle Preise verstehen sich ohne die gesetzliche Mehrwertsteuer. Bei gebuchten Mieten inkl. Probemonat entstehen Kosten nach automatischer Verlängerung." - }, - "in-app-purchases": { - "onceOff": "einmalig" + } }, - "legacyServices": { - "description": "Diese Erweiterung ist jetzt als Shopware Service verfügbar.", - "openServices": "Shopware Payments öffnen" + "installation": { + "successTitle": "Installation erfolgreich", + "successMessage": "{name} wurde erfolgreich installiert und aktiviert.", + "errorTitle": "Installation fehlgeschlagen", + "errorMessage": "{name} konnte nicht installiert werden. Bitte versuche es erneut oder installiere es manuell.", + "reloadRequiredTitle": "Neuladen erforderlich", + "reloadRequiredMessage": "{name} wurde installiert. Lade die Administration neu, um es zu verwenden.", + "reloadRequiredAction": "Jetzt neu laden" } - }, - "sw-extension-store-statistics-promotion": { - "app-name": "Shopware Analytics", - "app-description": "Erfasse wichtige Shop-Kennzahlen", - "go-to-app": "Jetzt ausprobieren" - }, - "sw-extension-store-accessibility": { - "accessibility-text": "Erfahre mehr zu Barrierefreiheit von Erweiterungen im Shopware Store", - "accessibility-more-information": "Jetzt mehr erfahren", - "accessibility-link": "https://store.shopware.com/de/ressourcen/barrierefreiheit-von-erweiterungen/" } } diff --git a/src/Resources/app/administration/src/module/sw-extension-store/snippet/en.json b/src/Resources/app/administration/src/module/sw-extension-store/snippet/en.json index 87614d63..24bc94e6 100644 --- a/src/Resources/app/administration/src/module/sw-extension-store/snippet/en.json +++ b/src/Resources/app/administration/src/module/sw-extension-store/snippet/en.json @@ -1,105 +1,136 @@ { "sw-extension-store": { + "component": { + "sw-extension-permissions-details-modal": { + "operationUpdate": "Edit", + "operationDelete": "Delete" + } + }, "general": { - "labelPriceFirstMonth": "{price} / 1. month", - "labelPriceOneTime": "{price}", - "labelPricePerMonth": "{price} /month", - "labelPricePerYear": "{price} /year", - "labelFromPricePerMonth": "from {price} /month", - "labelFree": "Free", - "labelSale": "% Sale", - "labelSavings": "{percentage}% discount", - "labelDiscountedPrice": "Free | {price} {discountedPrice}/month | {price} {discountedPrice}", "title": "Store", "taxNotice": "* All prices are exclusive of VAT" }, - "tabs": { - "apps": "Apps", - "themes": "Themes" - }, - "listing": { - "placeholderSearchBar": "Find extensions..." - }, - "updateWarning": { - "headline": "We've updated the Shopware Store.
    Take a moment to update to the latest version.", - "description": "Discover numerous great extensions that'll help you grow your business.", - "requestDescription": "You'll need to upgrade to the latest version to get new and updated extensions or themes for your shop.", - "update": "Update" - }, - "offline": { - "headline": "The Shopware Store is not available", - "description": "Check your internet connection or try again later." - }, - "detail": { - "enterpriseContactLinkText": "Contact us", - "enterpriseContactUrl": "https://www.shopware.com/en/enterprise-contact", - "enterpriseFeatureAlertText": "This extension is a component of the Enterprise Edition.", - "hasTrialPhase": "trial month included", - "openConfiguration": "Configuration", - "themeIsAlreadyInstalled": "Theme is already installed", - "extensionIsAlreadyInstalled": "Extension is already installed", - "inAppPurchases": { - "label": "In-App Purchases", - "paymentOptions": "Payment Options" - } + "in-app-purchases": { + "label": "In-App Purchases", + "paymentOptions": "Payment Options", + "onceOff": "one-time" }, - "typeLabels": { - "self-hosted": "Self hosted", - "cloud": "Cloud" + "legacyServices": { + "description": "This extension is now available as a Shopware Service.", + "openServices": "Open Shopware Payments" }, - "buy-modal": { - "paymentSelectionPlaceholder": "Select a payment method...", - "checkboxes": { - "permissionsAndAppProvider": { - "introText": "I am aware that this app needs further", - "buttonPermissions": "permissions", - "middleText": "and accept the", - "buttonAppProvider": "app provider", - "endText": "as new sub-processor of Shopware", - "appProviderStartText": "I accept the" + "purchase-confirmation": { + "backToCheckout": "Back to checkout", + "closeWindow": "Close window", + "modalTitle": "Checkout", + "checkout": { + "success": { + "title": "Your order was successful!", + "description": "Order confirmation email has been sent. The extension is now visible in your Shopware Account and in the Admin of your shop." + }, + "failed": { + "title": "Something went wrong", + "description": "Sorry, your purchase could not be completed.", + "documentationLink": "See documentation" + }, + "overview": { + "billingAddress": "Billing address", + "bookingDomain": "Booking domain", + "by": "by", + "change": "Change", + "changeBillingAddressUrl": "https://account.shopware.com/settings/data", + "paymentMethod": "Payment method", + "noPaymentMethod": "No payment method stored", + "addPaymentMethod": "Add payment method", + "paymentMethodBanner": { + "noPaymentMethod": { + "title": "Please provide a payment method to proceed", + "description": "For licensing Extensions in our store a payment method is required. Please add a payment method in your Shopware Account. To add it, click the button below.", + "action": "Add payment method" + }, + "change": { + "title": "Change payment method?", + "description": "The standard payment method that has been added to the Shopware Account will be used for this order. To change it, click the button below.

    Note: After updating the standard payment method in the Shopware Account it will be updated in the background and automatically be used if you submit this order. No reload required.", + "action": "Change payment method" + } + } + }, + "demo": { + "hint": { + "title": "Test license for demo environment", + "description": "You are booking this extension as a test license for your demo environment. This license is intended for testing purposes only and cannot be used in a production environment. No costs will apply in the demo test phase." + }, + "install": { + "label": "Install extension automatically after test license creation" + }, + "order": { + "submit": "Create test license", + "submit-and-install": "Create & install test license" + } + }, + "install": { + "title": "Installation", + "label": "Install extension automatically after purchase", + "hint": "Extension version is not marked as compatible with your shop version" + }, + "toc": { + "title": "Terms and Conditions", + "checkboxes": { + "gtc": { + "text": "I have read the {link} and agree with them.", + "link": "General Terms and Conditions" + }, + "permissions": { + "text": "I am aware that this extension needs additional permissions.", + "link": "Show permissions" + } + } + }, + "order": { + "firstMonth": "/first month", + "followingPerMonth": "Following {price}** /month", + "followingPerYear": "Following {price}** /year", + "forMonths": "for {months} months", + "forFirstYear": "for the first year", + "perMonth": "/month", + "perYear": "/year", + "submit": "Submit order", + "submit-and-install": "Submit order and install", + "then": "Then" + }, + "notes": { + "tax": "* All prices are quoted net of the statutory value-added tax" + }, + "loading": { + "title": { + "order": "Processing your order...", + "update": "Updating your extension data...", + "install": "Installing your extension...", + "reload": "Reloading your shop..." + }, + "description": "Please wait. This can take a few seconds." } }, - "legalTextModal": { - "title": "Sub-processor of Shopware" - }, - "rent": { - "priceDisplay": "{priceDisplay}/month", - "freeTrial": "30-day free trial", - "disclaimer": "Additional charges may apply.", - "followingDiscount": "following {price} /month for {months} months,", - "monthly": { - "discountInformation": "For {months} months, then {price} /month", - "cancellationInformation": "Cancelable monthly", - "finalPrice": "then {price} /month" + "permissions": { + "intro": { + "label": "Permissions", + "descriptionPermissions": "{extensionLabel} will get access to the components below.", + "descriptionDomains": "Some data will also be shared with the domains listed below." }, - "yearly": { - "discountInformation": "For 12 months, then {price} /year", - "cancellationInformation": "Cancelable annually", - "finalPrice": "then {price} /year" + "domains": { + "label": "Domains", + "description": "Some data will be shared with / transferred to the domains listed below." } - }, - "warnings": { - "paymentMeansRequiredText": "In order to purchase this app you'll have to", - "paymentMeansRequiredLinkText": "add a payment method" - }, - "plusVat": "* All prices are quoted net of the statutory value-added tax. With booked rents incl. trial month costs will apply upon automatic renewal." - }, - "in-app-purchases": { - "onceOff": "one-time" + } }, - "legacyServices": { - "description": "This extension is now available as a Shopware Service.", - "openServices": "Open Shopware Payments" + "installation": { + "successTitle": "Installation successful", + "successMessage": "{name} has been successfully installed and activated.", + "errorTitle": "Installation failed", + "errorMessage": "{name} could not be installed. Please try again or install it manually.", + "reloadRequiredTitle": "Reload required", + "reloadRequiredMessage": "{name} has been installed. Reload the administration to start using it.", + "reloadRequiredAction": "Reload now" } - }, - "sw-extension-store-statistics-promotion": { - "app-name": "Shopware Analytics", - "app-description": "Unlock store performance metrics", - "go-to-app": "Try it out now" - }, - "sw-extension-store-accessibility": { - "accessibility-text": "Learn more about accessibility of extensions in the Shopware Store", - "accessibility-more-information": "Learn more now", - "accessibility-link": "https://store.shopware.com/en/resources/accessibility-of-extensions/" } } diff --git a/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-context.store.ts b/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-context.store.ts new file mode 100644 index 00000000..959639a6 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-context.store.ts @@ -0,0 +1,85 @@ +export type ExtensionStoreContextState = { + licenseHost: string | null; + skyBridgeStoreVersion: string | null; + iframeUrl: string | null; +}; + +const FALLBACK_IFRAME_URL = 'https://sky-bridge-store.production.shopware.in'; + +const isLoggedIn = () => Shopware.Store.get('shopwareExtensions').userInfo !== null; + +let iframeUrlLoadPromise: Promise | null = null; +let licenseHostLoadPromise: Promise | null = null; + +export default Shopware.Store.register('extensionStoreContext', { + state: (): ExtensionStoreContextState => ({ + licenseHost: null, + skyBridgeStoreVersion: null, + iframeUrl: null, + }), + actions: { + loadLicenseHost(): Promise { + // The license host is only valid for logged-in users. + if (!isLoggedIn()) { + this.licenseHost = null; + licenseHostLoadPromise = null; + + return Promise.resolve(null); + } + + if (this.licenseHost) { + return Promise.resolve(this.licenseHost); + } + + if (licenseHostLoadPromise) { + return licenseHostLoadPromise; + } + + licenseHostLoadPromise = Shopware.Service('systemConfigApiService') + .getValues('core.store') + .then((config: Record) => { + this.licenseHost = config['core.store.licenseHost'] ?? null; + + return this.licenseHost; + }) + .catch(() => { + licenseHostLoadPromise = null; + + return null; + }); + + return licenseHostLoadPromise; + }, + updateSkyBridgeStoreVersion(version: string): void { + this.skyBridgeStoreVersion = version; + }, + resetSkyBridgeStoreVersion(): void { + this.skyBridgeStoreVersion = null; + }, + loadIframeUrl(): Promise { + if (this.iframeUrl) { + return Promise.resolve(this.iframeUrl); + } + + if (iframeUrlLoadPromise) { + return iframeUrlLoadPromise; + } + + iframeUrlLoadPromise = Shopware.Service('systemConfigApiService') + .getValues('SwagExtensionStore.config') + .then((config: Record) => { + this.iframeUrl = config['SwagExtensionStore.config.iframeUrl'] ?? FALLBACK_IFRAME_URL; + + return this.iframeUrl; + }) + .catch(() => { + this.iframeUrl = FALLBACK_IFRAME_URL; + iframeUrlLoadPromise = null; + + return this.iframeUrl; + }); + + return iframeUrlLoadPromise; + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-purchase-confirmation.store.spec.ts b/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-purchase-confirmation.store.spec.ts new file mode 100644 index 00000000..2844240d --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-purchase-confirmation.store.spec.ts @@ -0,0 +1,354 @@ +import { flushPromises } from '@vue/test-utils'; +import extensionStorePurchaseConfirmationStore, { + type PurchaseConfirmationOnConfirmCallbackResult, +} from 'SwagExtensionStore/module/sw-extension-store/store/extension-store-purchase-confirmation.store'; +import { pageReload } from 'SwagExtensionStore/util/page-reload'; +import { trackExtensionStoreEvent } from 'SwagExtensionStore/util/telemetry'; +import type { ExtensionStoreBasket } from 'SwagExtensionStore/module/sw-extension-store/types/extension-store-basket.types'; + +jest.mock('SwagExtensionStore/util/telemetry', () => ({ + trackExtensionStoreEvent: jest.fn(() => Promise.resolve()), +})); + +const trackMock = trackExtensionStoreEvent as jest.MockedFunction; + +const cart = { + positions: [ + { + extension: { + id: 'extension-id', + name: 'SwagApp', + }, + netPrice: 19.99, + taxRate: 19, + firstMonthFree: false, + discountAppliesForMonths: null, + pseudoPrice: 19.99, + }, + ], +} as unknown as ExtensionStoreBasket; + +const openModalWith = (result: PurchaseConfirmationOnConfirmCallbackResult | Promise) => { + extensionStorePurchaseConfirmationStore().openModal({ + cart, + paymentMeans: [], + isCompatible: true, + onConfirm: () => Promise.resolve(result), + onCancel: jest.fn(), + }); +}; + +/** Opens the modal with a purchase that only finishes once the returned `finishPurchase` is called. */ +const openModalWithPendingPurchase = (result: PurchaseConfirmationOnConfirmCallbackResult) => { + let finishPurchase = (): void => {}; + const onCancel = jest.fn(); + + extensionStorePurchaseConfirmationStore().openModal({ + cart, + paymentMeans: [], + isCompatible: true, + onConfirm: () => new Promise((resolve) => { + finishPurchase = () => resolve(result); + }), + onCancel, + }); + + return { + onCancel, + finishPurchase: () => finishPurchase(), + }; +}; + +describe('SwagExtensionStore/module/sw-extension-store/store/extension-store-purchase-confirmation.store', () => { + let reloadAfterSpy: jest.SpyInstance; + let reloadSpy: jest.SpyInstance; + let createNotificationSpy: jest.SpyInstance; + + beforeEach(() => { + extensionStorePurchaseConfirmationStore().$reset(); + trackMock.mockClear(); + trackMock.mockImplementation(() => Promise.resolve()); + reloadAfterSpy = jest.spyOn(pageReload, 'reloadAfter').mockResolvedValue(); + reloadSpy = jest.spyOn(pageReload, 'reload').mockImplementation(() => {}); + createNotificationSpy = jest.spyOn(Shopware.Store.get('notification'), 'createNotification') + .mockImplementation(() => null); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('confirm with a required reload', () => { + it('should show the reload hint and keep loading until the page is reloaded', async () => { + openModalWith({ success: true, requiresReload: true }); + + await extensionStorePurchaseConfirmationStore().confirm(); + + const store = extensionStorePurchaseConfirmationStore(); + expect(store.isLoading).toBe(true); + expect(store.isSuccessful).toBe(true); + expect(reloadAfterSpy).toHaveBeenCalledWith(1_500); + }); + + it('should track the successful purchase before reloading the page', async () => { + let dispatchTracking = (): void => {}; + trackMock.mockImplementation(() => new Promise((resolve) => { + dispatchTracking = () => resolve(); + })); + + openModalWith({ success: true, requiresReload: true }); + + const confirmed = extensionStorePurchaseConfirmationStore().confirm(); + await flushPromises(); + + // The tracking event has been handed over, but did not reach the gateway yet. + expect(trackMock).toHaveBeenCalledWith('extension_purchase_successful', expect.objectContaining({ + extension_id: 'extension-id', + extension_name: 'SwagApp', + })); + expect(reloadAfterSpy).not.toHaveBeenCalled(); + + dispatchTracking(); + await confirmed; + + expect(reloadAfterSpy).toHaveBeenCalledTimes(1); + }); + + }); + + describe('confirm without a required reload', () => { + it('should finish loading and not reload the page when no extension was installed', async () => { + openModalWith({ success: true, requiresReload: false }); + + await extensionStorePurchaseConfirmationStore().confirm(); + + const store = extensionStorePurchaseConfirmationStore(); + expect(store.isLoading).toBe(false); + expect(store.isSuccessful).toBe(true); + expect(store.checkoutStep).toBeNull(); + expect(reloadAfterSpy).not.toHaveBeenCalled(); + }); + + it('should not reload the page when the result does not request it', async () => { + openModalWith({ success: true }); + + await extensionStorePurchaseConfirmationStore().confirm(); + + expect(extensionStorePurchaseConfirmationStore().isLoading).toBe(false); + expect(reloadAfterSpy).not.toHaveBeenCalled(); + }); + + it('should not reload the page when the modal was closed while the purchase was in progress', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { finishPurchase } = openModalWithPendingPurchase({ success: true, requiresReload: true }); + + const confirmed = store.confirm(); + await flushPromises(); + + // The user closes the modal and possibly navigates away while the purchase is still running. + store.closeModal(); + finishPurchase(); + await confirmed; + + expect(store.isOpen).toBe(false); + expect(store.isLoading).toBe(false); + expect(reloadAfterSpy).not.toHaveBeenCalled(); + }); + + it('should offer the reload when the modal was closed while the extension was installed', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { finishPurchase } = openModalWithPendingPurchase({ success: true, requiresReload: true }); + + const confirmed = store.confirm(); + await flushPromises(); + + store.closeModal(); + finishPurchase(); + await confirmed; + + expect(createNotificationSpy).toHaveBeenCalledTimes(1); + expect(createNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + variant: 'info', + growl: true, + autoClose: false, + })); + + // The notification offers the reload the store did not perform on its own. + const notification = createNotificationSpy.mock.calls[0][0] as { + actions: { method: () => void }[]; + }; + expect(reloadSpy).not.toHaveBeenCalled(); + + notification.actions[0].method(); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + }); + + it('should not reload the page when another purchase was started in the meantime', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { finishPurchase } = openModalWithPendingPurchase({ success: true, requiresReload: true }); + + const confirmed = store.confirm(); + await flushPromises(); + + // The user closes the modal and starts another purchase while the first one is still running. + store.closeModal(); + openModalWith({ success: true }); + finishPurchase(); + await confirmed; + + expect(store.isOpen).toBe(true); + expect(reloadAfterSpy).not.toHaveBeenCalled(); + expect(createNotificationSpy).toHaveBeenCalledTimes(1); + }); + + it('should not offer the reload when nothing was installed', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { finishPurchase } = openModalWithPendingPurchase({ success: true, requiresReload: false }); + + const confirmed = store.confirm(); + await flushPromises(); + + store.closeModal(); + finishPurchase(); + await confirmed; + + expect(createNotificationSpy).not.toHaveBeenCalled(); + }); + + it('should track the purchase result when the modal was closed while the purchase was in progress', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { finishPurchase } = openModalWithPendingPurchase({ success: true, requiresReload: true }); + + const confirmed = store.confirm(); + await flushPromises(); + + // `closeModal` resets the cart, the tracked event must still contain the purchased extension. + store.closeModal(); + finishPurchase(); + await confirmed; + + expect(trackMock).toHaveBeenCalledWith('extension_purchase_successful', expect.objectContaining({ + extension_id: 'extension-id', + extension_name: 'SwagApp', + })); + }); + + it('should not reload the page when the purchase failed', async () => { + openModalWith({ + success: false, + requiresReload: true, + title: 'error-title', + description: 'error-description', + documentationLink: 'error-link', + }); + + await extensionStorePurchaseConfirmationStore().confirm(); + + const store = extensionStorePurchaseConfirmationStore(); + expect(store.isLoading).toBe(false); + expect(store.isSuccessful).toBe(false); + expect(store.errorTitle).toBe('error-title'); + expect(store.errorDescription).toBe('error-description'); + expect(store.errorDocumentationLink).toBe('error-link'); + expect(trackMock).toHaveBeenCalledWith('extension_purchase_failed', expect.any(Object)); + expect(reloadAfterSpy).not.toHaveBeenCalled(); + }); + + it('should track the failed purchase when the modal was closed while the purchase was in progress', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { finishPurchase } = openModalWithPendingPurchase({ success: false }); + + const confirmed = store.confirm(); + await flushPromises(); + + store.closeModal(); + finishPurchase(); + await confirmed; + + expect(trackMock).toHaveBeenCalledWith('extension_purchase_failed', expect.objectContaining({ + extension_id: 'extension-id', + })); + expect(createNotificationSpy).not.toHaveBeenCalled(); + }); + }); + + describe('cancel', () => { + it('should report the cancellation when the purchase was not confirmed yet', () => { + const store = extensionStorePurchaseConfirmationStore(); + const { onCancel } = openModalWithPendingPurchase({ success: true }); + + store.cancel(); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledWith('extension_purchase_cancelled', expect.any(Object)); + expect(store.isOpen).toBe(false); + }); + + it('should not report a cancellation while the purchase is still running', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { onCancel, finishPurchase } = openModalWithPendingPurchase({ success: true, requiresReload: true }); + + const confirmed = store.confirm(); + await flushPromises(); + + // A running purchase cannot be aborted, so it must not be reported as cancelled either. + store.cancel(); + + expect(onCancel).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalledWith('extension_purchase_cancelled', expect.anything()); + expect(store.isOpen).toBe(false); + + finishPurchase(); + await confirmed; + }); + + it('should not report a cancellation when closing the result of a failed purchase', async () => { + const store = extensionStorePurchaseConfirmationStore(); + const { onCancel, finishPurchase } = openModalWithPendingPurchase({ success: false }); + + const confirmed = store.confirm(); + await flushPromises(); + finishPurchase(); + await confirmed; + + // The purchase already reported its failure, closing the result must not report it again. + store.cancel(); + + expect(onCancel).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalledWith('extension_purchase_cancelled', expect.anything()); + }); + + it('should report the cancellation of a failed basket creation', () => { + const store = extensionStorePurchaseConfirmationStore(); + const onCancel = jest.fn(); + + store.openErrorModal('error-title', 'error-description', 'error-link', onCancel); + store.cancel(); + + expect(store.isFailedOnBasketCreation).toBe(false); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + }); + + describe('openModal', () => { + it('should reset the state of a previous purchase', () => { + const store = extensionStorePurchaseConfirmationStore(); + + store.isSubmitted = true; + store.isSuccessful = true; + store.checkoutStep = 'reload'; + store.errorTitle = 'previous-error'; + store.isFailedOnBasketCreation = true; + + openModalWith({ success: true }); + + expect(store.isOpen).toBe(true); + expect(store.isSubmitted).toBe(false); + expect(store.isSuccessful).toBe(false); + expect(store.checkoutStep).toBeNull(); + expect(store.errorTitle).toBeNull(); + expect(store.isFailedOnBasketCreation).toBe(false); + }); + }); +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-purchase-confirmation.store.ts b/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-purchase-confirmation.store.ts new file mode 100644 index 00000000..794017b8 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/store/extension-store-purchase-confirmation.store.ts @@ -0,0 +1,240 @@ +import type { ExtensionStoreBasket, ExtensionStorePaymentMean } from '../types/extension-store-basket.types'; +import { trackExtensionStoreEvent } from 'SwagExtensionStore/util/telemetry'; +import { pageReload } from 'SwagExtensionStore/util/page-reload'; + +export type PurchaseConfirmationCheckoutStep = 'order' | 'update' | 'install' | 'reload'; + +export type PurchaseConfirmationOnConfirmCallbackResult = { + success: boolean; + requiresReload?: boolean; + title?: string; + description?: string; + documentationLink?: string; +}; +export type PurchaseConfirmationOnConfirmCallback = () => Promise; +export type PurchaseConfirmationOnCancelCallback = () => void; + +export type PurchaseConfirmationModalData = { + cart: ExtensionStoreBasket; + paymentMeans: ExtensionStorePaymentMean[]; + isCompatible: boolean; + onConfirm: PurchaseConfirmationOnConfirmCallback; + onCancel: PurchaseConfirmationOnCancelCallback; +}; + +type PurchaseConfirmationState = { + /** + * Increases with every opened modal. A purchase that outlived its modal uses it to tell whether + * the modal currently on screen is still its own. + */ + purchaseId: number; + isFailedOnBasketCreation: boolean; + isOpen: boolean; + isLoading: boolean; + isSubmitted: boolean; + checkoutStep: PurchaseConfirmationCheckoutStep | null; + isSuccessful: boolean; + cartData: ExtensionStoreBasket | null; + paymentMeansData: ExtensionStorePaymentMean[] | null; + isCompatible: boolean; + onConfirm: PurchaseConfirmationOnConfirmCallback | null; + onCancel: PurchaseConfirmationOnCancelCallback | null; + errorTitle: string | null; + errorDescription: string | null; + errorDocumentationLink: string | null; +}; + +const trackExtensionStorePurchaseEvent = ( + cartData: ExtensionStoreBasket | null, + name: 'initiated' | 'confirmed' | 'successful' | 'failed' | 'cancelled', +): Promise => { + const [position] = cartData?.positions ?? []; + if (!position) { + return Promise.resolve(); + } + + return trackExtensionStoreEvent(`extension_purchase_${name}`, { + extension_id: position.extension.id, + extension_name: position.extension.name, + net_price: position.netPrice, + tax_rate: position.taxRate, + is_first_month_free: position.firstMonthFree, + discount_applies_for_months: position.discountAppliesForMonths, + pseudo_price: position.pseudoPrice, + }); +}; + +/** + * The extension is installed at this point, but the running administration does not know about it + * yet. Reloading unprompted would be unexpected while the modal is closed, so the reload is offered + * to the user instead. + */ +const notifyReloadRequired = (cartData: ExtensionStoreBasket | null): void => { + const [position] = cartData?.positions ?? []; + if (!position) { + return; + } + + const snippetService = Shopware.Snippet as unknown as { + tc: (key: string, params?: Record) => string; + }; + + Shopware.Store.get('notification').createNotification({ + variant: 'info', + growl: true, + autoClose: false, + title: snippetService.tc('sw-extension-store.installation.reloadRequiredTitle'), + message: snippetService.tc('sw-extension-store.installation.reloadRequiredMessage', { + name: String(position.extension.name), + }), + actions: [ + { + label: snippetService.tc('sw-extension-store.installation.reloadRequiredAction'), + method: () => pageReload.reload(), + }, + ], + }); +}; + +/** + * Duration the 'reload' hint is shown before the page is reloaded. Must stay above the + * telemetry gateway's flush interval (1s), so the tracked purchase events are sent out + * before the page is torn down. + */ +const RELOAD_DELAY_MS = 1_500; + +export default Shopware.Store.register('extensionStorePurchaseConfirmation', { + state: (): PurchaseConfirmationState => ({ + purchaseId: 0, + isFailedOnBasketCreation: false, + isOpen: false, + isLoading: false, + isSubmitted: false, + checkoutStep: null, + isSuccessful: false, + cartData: null, + paymentMeansData: null, + isCompatible: false, + onConfirm: null, + onCancel: null, + errorTitle: null, + errorDescription: null, + errorDocumentationLink: null, + }), + + actions: { + openModal(data: PurchaseConfirmationModalData): void { + // A previous purchase might still be running detached from its closed modal and write to + // the state after it finished. Start from a clean state, so nothing leaks into this modal. + this.closeModal(); + + this.purchaseId += 1; + this.cartData = data.cart; + this.paymentMeansData = data.paymentMeans; + this.isCompatible = data.isCompatible; + this.onConfirm = data.onConfirm; + this.onCancel = data.onCancel; + this.isOpen = true; + + void trackExtensionStorePurchaseEvent(this.cartData, 'initiated'); + }, + + async confirm(): Promise { + if (!this.onConfirm) { + return; + } + + // The modal can be closed while the purchase is running, which resets `cartData`. Keep a + // reference, so the tracking events below are still dispatched for the purchased extension. + const cartData = this.cartData; + const purchaseId = this.purchaseId; + + void trackExtensionStorePurchaseEvent(cartData, 'confirmed'); + + this.isSubmitted = true; + this.isLoading = true; + + const result = await this.onConfirm().catch((): PurchaseConfirmationOnConfirmCallbackResult => ({ + success: false, + })); + + this.isSuccessful = result.success; + let trackingDispatched: Promise; + if (this.isSuccessful) { + this.onCancel = null; + this.onConfirm = null; + trackingDispatched = trackExtensionStorePurchaseEvent(cartData, 'successful'); + } else { + this.errorTitle = result.title ?? null; + this.errorDescription = result.description ?? null; + this.errorDocumentationLink = result.documentationLink ?? null; + trackingDispatched = trackExtensionStorePurchaseEvent(cartData, 'failed'); + } + + if (this.isSuccessful && result.requiresReload) { + // Only reload on our own while the modal still shows this purchase. If it was closed, + // the user might have navigated away or even started another purchase in the meantime, + // where a reload would be unexpected. + if (this.isOpen && this.purchaseId === purchaseId) { + // Ensure the tracking event is sent before the page is reloaded, otherwise it will be lost. + await trackingDispatched; + await pageReload.reloadAfter(RELOAD_DELAY_MS); + + return; + } + + notifyReloadRequired(cartData); + } + + this.isLoading = false; + }, + + cancel(): void { + // A submitted purchase cannot be aborted, so closing the modal must neither report a failure + // nor track a cancellation. The purchase itself publishes the real result once it finished. + if (!this.isSubmitted) { + if (this.onCancel) { + this.onCancel(); + } + + if (!this.isSuccessful) { + void trackExtensionStorePurchaseEvent(this.cartData, 'cancelled'); + } + } + + this.closeModal(); + }, + + closeModal(): void { + this.isOpen = false; + this.isLoading = false; + this.isSubmitted = false; + this.checkoutStep = null; + this.isSuccessful = false; + this.isFailedOnBasketCreation = false; + this.cartData = null; + this.paymentMeansData = null; + this.onConfirm = null; + this.onCancel = null; + this.errorTitle = null; + this.errorDescription = null; + this.errorDocumentationLink = null; + }, + + openErrorModal( + errorTitle: string, + errorDescription: string, + errorDocumentationLink: string, + onCancel: PurchaseConfirmationOnCancelCallback, + ): void { + this.closeModal(); + + this.onCancel = onCancel; + this.errorTitle = errorTitle; + this.errorDescription = errorDescription; + this.errorDocumentationLink = errorDocumentationLink; + this.isFailedOnBasketCreation = true; + this.isOpen = true; + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-extension-store/types/extension-store-basket.types.ts b/src/Resources/app/administration/src/module/sw-extension-store/types/extension-store-basket.types.ts new file mode 100644 index 00000000..6b3c27d8 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-extension-store/types/extension-store-basket.types.ts @@ -0,0 +1,101 @@ +type ExtensionStoreBasketBillingAddress = { + companyName: string; + fullName?: string; + street: string; + zip: string; + city: string; + country: string; +}; + +type ExtensionStoreBasketShop = { + id: number; + domain: string; + /** + * This is only filled for on-premise shops. + * SaaS/cloud shops will always receive `null`. + */ + kind: 'standard' | 'demo' | null; +}; + +type ExtensionStoreBasketExtensionProducer = { + id: number; + name: string; + storeLink?: string; +}; + +type ExtensionStoreBasketExtensionPermission = { + entity: string; + operation: string; +}; + +type ExtensionStoreBasketExtension = { + id: number; + name: string; + type: 'app' | 'plugin'; + icon: string; + label: string; + producer: ExtensionStoreBasketExtensionProducer; + permissions: Record; + domains: string[]; +}; + +type ExtensionStoreBasketVariant = { + id: number; + name: string; + label: string | null; + duration: number | null; +}; + +type ExtensionStoreBasketPosition = { + extension: ExtensionStoreBasketExtension; + variant: ExtensionStoreBasketVariant; + taxRate: number | null; + netPrice: number; + taxValue: number; + grossPrice: number; + firstMonthFree: boolean; + pseudoPrice: number | null; + discountAppliesForMonths: number | null; + legalText: string | null; +}; + +type ExtensionStoreBasketPayment = { + chargingAmount: number; + paymentMean: { id: number } | null; + paymentText: string | null; + paymentTextLabel: string | null; + paymentMeanRequired: boolean; + registrationUrl: string; +}; + +type ExtensionStoreBasket = { + billingAddress: ExtensionStoreBasketBillingAddress; + bookingShop: ExtensionStoreBasketShop & { balance: number }; + licenseShop: ExtensionStoreBasketShop; + /** @deprecated use licenseShop - kept for backwards compatibility */ + shop: ExtensionStoreBasketShop; + positions: ExtensionStoreBasketPosition[]; + netPrice: number; + taxRate: number; + taxValue: number; + grossPrice: number; + payment: ExtensionStoreBasketPayment; + legalText: string | null; +}; + +type ExtensionStorePaymentMean = { + id: number; + type: string; + label: string; + default: boolean; +}; + +export type { + ExtensionStoreBasketShop, + ExtensionStoreBasketExtension, + ExtensionStoreBasketVariant, + ExtensionStoreBasketPosition, + ExtensionStoreBasketPayment, + ExtensionStoreBasket, + ExtensionStorePaymentMean, +}; diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/index.ts b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/index.ts index bb8bb05a..7f149b62 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/index.ts +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/index.ts @@ -12,7 +12,7 @@ export default Shopware.Component.wrapComponentConfig({ props: { state: { - type: String as PropType<'error' | 'success' | 'purchase'>, + type: String as PropType<'error' | 'success' | 'purchase' | 'info'>, required: true, }, tosAccepted: { @@ -32,7 +32,7 @@ export default Shopware.Component.wrapComponentConfig({ computed: { show() { - return ['error', 'success', 'purchase'].includes(this.state); + return ['error', 'success', 'purchase', 'info'].includes(this.state); }, disabled() { @@ -44,6 +44,7 @@ export default Shopware.Component.wrapComponentConfig({ case 'error': return this.$t('sw-in-app-purchase-checkout-button.tryAgainButton'); case 'success': + case 'info': return this.$t('sw-in-app-purchase-checkout-button.closeButton'); case 'purchase': return this.$t('sw-in-app-purchase-checkout-button.purchaseButton'); diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/sw-in-app-purchase-checkout-button.spec.js b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/sw-in-app-purchase-checkout-button.spec.js index b88d3039..7e883df5 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/sw-in-app-purchase-checkout-button.spec.js +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-button/sw-in-app-purchase-checkout-button.spec.js @@ -53,6 +53,9 @@ describe('sw-in-app-purchase-checkout-button', () => { await wrapper.setProps({ state: 'purchase' }); expect(wrapper.vm.show).toBe(true); + await wrapper.setProps({ state: 'info' }); + expect(wrapper.vm.show).toBe(true); + await wrapper.setProps({ state: 'unknown' }); expect(wrapper.vm.show).toBe(false); }); @@ -75,6 +78,9 @@ describe('sw-in-app-purchase-checkout-button', () => { await wrapper.setProps({ state: 'purchase' }); expect(wrapper.vm.text).toBe('sw-in-app-purchase-checkout-button.purchaseButton'); + await wrapper.setProps({ state: 'info' }); + expect(wrapper.vm.text).toBe('sw-in-app-purchase-checkout-button.closeButton'); + await wrapper.setProps({ state: 'random' }); expect(wrapper.vm.text).toBeNull(); }); diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/index.ts b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/index.ts index 9487293f..e749eff8 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/index.ts +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/index.ts @@ -1,6 +1,16 @@ import template from './sw-in-app-purchase-checkout-state.html.twig'; import './sw-in-app-purchase-checkout-state.scss'; +const SNIPPET_ROOT = 'sw-in-app-purchase-checkout-state'; + +/** + * States that describe their reason with a snippet key, mapped to the namespace that key lives in. + */ +const REASON_NAMESPACES: Record = { + error: 'errors', + info: 'infos', +}; + /** * @private */ @@ -9,10 +19,10 @@ export default Shopware.Component.wrapComponentConfig({ props: { state: { - type: String as PropType<'loading' | 'error' | 'success'>, + type: String as PropType<'loading' | 'error' | 'success' | 'info'>, required: true, }, - error: { + reason: { type: String, required: false, default: null, @@ -25,6 +35,7 @@ export default Shopware.Component.wrapComponentConfig({ 'is--error': this.state === 'error', 'is--success': this.state === 'success', 'is--loading': this.state === 'loading', + 'is--info': this.state === 'info', }; }, @@ -34,6 +45,8 @@ export default Shopware.Component.wrapComponentConfig({ return 'solid-times'; case 'success': return 'solid-checkmark'; + case 'info': + return 'solid-info-circle'; default: return null; } @@ -42,9 +55,9 @@ export default Shopware.Component.wrapComponentConfig({ title(): string | null { switch (this.state) { case 'error': - return this.$t('sw-in-app-purchase-checkout-state.errorTitle'); case 'success': - return this.$t('sw-in-app-purchase-checkout-state.successTitle'); + case 'info': + return this.resolveSnippet('title'); default: return null; } @@ -53,24 +66,35 @@ export default Shopware.Component.wrapComponentConfig({ subtitle(): string | null { switch (this.state) { case 'error': - return this.errorSnippet; case 'success': - return this.$t('sw-in-app-purchase-checkout-state.successSubtitle'); + case 'info': + return this.resolveSnippet('subtitle'); default: return null; } }, + }, + + methods: { + resolveSnippet(part: 'title' | 'subtitle'): string { + const fallback = `${SNIPPET_ROOT}.${this.state}${part === 'title' ? 'Title' : 'Subtitle'}`; + const namespace = REASON_NAMESPACES[this.state]; + + if (!namespace || !this.reason) { + return this.$t(fallback); + } + + const base = `${SNIPPET_ROOT}.${namespace}.${this.reason}`; - errorSnippet(): string { - if (!this.error) { - return this.$t('sw-in-app-purchase-checkout-state.errorSubtitle'); + if (this.$te(`${base}.${part}`)) { + return this.$t(`${base}.${part}`); } - if (this.$te(`sw-in-app-purchase-checkout-state.errors.${this.error}`)) { - return this.$t(`sw-in-app-purchase-checkout-state.errors.${this.error}`); + if (part === 'subtitle' && this.$te(base)) { + return this.$t(base); } - return this.$t('sw-in-app-purchase-checkout-state.errorSubtitle'); + return this.$t(fallback); }, }, }); diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.scss b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.scss index 97826442..b8321b8d 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.scss +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.scss @@ -46,4 +46,11 @@ background-color: var(--color-background-positive-default); } } + + &.is--info { + .sw-in-app-purchase-checkout-state__icon { + color: var(--color-interaction-primary-default); + background-color: var(--color-background-brand-default); + } + } } diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.spec.js b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.spec.js index 3f56d7d4..426b3300 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.spec.js +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout-state/sw-in-app-purchase-checkout-state.spec.js @@ -14,7 +14,12 @@ async function createWrapper(props) { }, mocks: { $te: (key) => { - return key === 'sw-in-app-purchase-checkout-state.errors.this-error-exists'; + return [ + 'sw-in-app-purchase-checkout-state.errors.this-error-exists', + 'sw-in-app-purchase-checkout-state.infos.this-info-exists.title', + 'sw-in-app-purchase-checkout-state.infos.this-info-exists.subtitle', + 'sw-in-app-purchase-checkout-state.infos.subtitle-only.subtitle', + ].includes(key); }, }, }, @@ -31,15 +36,19 @@ describe('sw-in-app-purchase-checkout-state', () => { it('should compute classes correctly', async () => { wrapper = await createWrapper({ state: 'error' }); - expect(wrapper.vm.classes).toEqual({ 'is--error': true, 'is--success': false, 'is--loading': false }); + expect(wrapper.vm.classes).toEqual({ 'is--error': true, 'is--success': false, 'is--loading': false, 'is--info': false }); await wrapper.setProps({ state: 'success' }); await wrapper.vm.$nextTick(); - expect(wrapper.vm.classes).toEqual({ 'is--error': false, 'is--success': true, 'is--loading': false }); + expect(wrapper.vm.classes).toEqual({ 'is--error': false, 'is--success': true, 'is--loading': false, 'is--info': false }); await wrapper.setProps({ state: 'loading' }); await wrapper.vm.$nextTick(); - expect(wrapper.vm.classes).toEqual({ 'is--error': false, 'is--success': false, 'is--loading': true }); + expect(wrapper.vm.classes).toEqual({ 'is--error': false, 'is--success': false, 'is--loading': true, 'is--info': false }); + + await wrapper.setProps({ state: 'info' }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.classes).toEqual({ 'is--error': false, 'is--success': false, 'is--loading': false, 'is--info': true }); }); it('should compute icon correctly', async () => { @@ -50,6 +59,10 @@ describe('sw-in-app-purchase-checkout-state', () => { await wrapper.vm.$nextTick(); expect(wrapper.vm.icon).toBe('solid-checkmark'); + await wrapper.setProps({ state: 'info' }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.icon).toBe('solid-info-circle'); + await wrapper.setProps({ state: 'loading' }); await wrapper.vm.$nextTick(); expect(wrapper.vm.icon).toBeNull(); @@ -70,11 +83,11 @@ describe('sw-in-app-purchase-checkout-state', () => { it('should compute subtitle correctly', async () => { // error not found in SBP or allowed - wrapper = await createWrapper({ state: 'error', error: 'error is not allowed' }); + wrapper = await createWrapper({ state: 'error', reason: 'error is not allowed' }); expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.errorSubtitle')); // error is not set - wrapper = await createWrapper({ state: 'error', error: null }); + wrapper = await createWrapper({ state: 'error', reason: null }); expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.errorSubtitle')); await wrapper.setProps({ state: 'success' }); @@ -85,4 +98,46 @@ describe('sw-in-app-purchase-checkout-state', () => { await wrapper.vm.$nextTick(); expect(wrapper.vm.subtitle).toBeNull(); }); + + it('should use the flat error snippet as subtitle when the reason is known', async () => { + wrapper = await createWrapper({ state: 'error', reason: 'this-error-exists' }); + + expect(wrapper.vm.title).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.errorTitle')); + expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.errors.this-error-exists')); + }); + + it('should use the reason specific title and subtitle in the info state', async () => { + wrapper = await createWrapper({ state: 'info', reason: 'this-info-exists' }); + + expect(wrapper.vm.title).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infos.this-info-exists.title')); + expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infos.this-info-exists.subtitle')); + }); + + it('should fall back to the generic wording per missing part in the info state', async () => { + // only a subtitle is defined for this reason, the title falls back + wrapper = await createWrapper({ state: 'info', reason: 'subtitle-only' }); + + expect(wrapper.vm.title).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infoTitle')); + expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infos.subtitle-only.subtitle')); + }); + + it('should fall back to the generic wording for an unknown info reason', async () => { + wrapper = await createWrapper({ state: 'info', reason: 'this-info-does-not-exist' }); + + expect(wrapper.vm.title).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infoTitle')); + expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infoSubtitle')); + + // reason is not set at all + wrapper = await createWrapper({ state: 'info', reason: null }); + + expect(wrapper.vm.title).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infoTitle')); + expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.infoSubtitle')); + }); + + it('should ignore the reason in the success state', async () => { + wrapper = await createWrapper({ state: 'success', reason: 'this-error-exists' }); + + expect(wrapper.vm.title).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.successTitle')); + expect(wrapper.vm.subtitle).toBe(wrapper.vm.$t('sw-in-app-purchase-checkout-state.successSubtitle')); + }); }); diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/index.ts b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/index.ts index adadc89d..9d06056b 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/index.ts +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/index.ts @@ -2,6 +2,7 @@ import type * as IAP from 'SwagExtensionStore/module/sw-in-app-purchases/types'; import template from './sw-in-app-purchase-checkout.html.twig'; import './sw-in-app-purchase-checkout.scss'; import { trackExtensionStoreEvent } from 'SwagExtensionStore/util/telemetry'; +import { hasDemoShopBundle } from 'SwagExtensionStore/util/demo-shop'; /** * @private @@ -19,7 +20,7 @@ export default Shopware.Component.wrapComponentConfig({ data() { return { - state: 'loading' as 'loading' | 'purchase' | 'error' | 'success', + state: 'loading' as 'loading' | 'purchase' | 'error' | 'success' | 'info', store: Shopware.Store.get('inAppPurchaseCheckout'), inAppPurchaseCart: null as IAP.InAppPurchaseCart | null, extension: null as IAP.Extension | null, @@ -27,7 +28,7 @@ export default Shopware.Component.wrapComponentConfig({ tosAccepted: false, gtcAccepted: false, variant: null as string | null, - errorMessage: null as string | null, + reason: null as string | null, }; }, @@ -77,8 +78,8 @@ export default Shopware.Component.wrapComponentConfig({ this.state = 'purchase'; this.trackCheckoutEvent('initiated'); }).catch ((errorResponse: ErrorResponse) => { - Shopware.Utils.debug.error("checkout-iap", errorResponse); - this.errorMessage = this.getError(errorResponse); + Shopware.Utils.debug.error('checkout-iap', errorResponse); + this.reason = this.getErrorApiCode(errorResponse); this.state = 'error'; }); }, @@ -89,6 +90,14 @@ export default Shopware.Component.wrapComponentConfig({ return; } + // Demo shops can never complete an in-app purchase, so the request is skipped entirely + // instead of letting the API reject it. Must not reset, that would close the modal. + if (hasDemoShopBundle()) { + this.reason = 'demoShop'; + this.state = 'info'; + return; + } + this.state = 'loading'; await Promise.all([ @@ -104,8 +113,8 @@ export default Shopware.Component.wrapComponentConfig({ return this.createCart(this.purchase.preselectedVariant); }).catch ((errorResponse: ErrorResponse)=> { - Shopware.Utils.debug.error("checkout-iap", errorResponse); - this.errorMessage = this.getError(errorResponse); + Shopware.Utils.debug.error('checkout-iap', errorResponse); + this.reason = this.getErrorApiCode(errorResponse); this.state = 'error'; }); }, @@ -126,8 +135,8 @@ export default Shopware.Component.wrapComponentConfig({ this.state = 'success'; this.trackCheckoutEvent('successful'); }).catch((errorResponse: ErrorResponse) => { - Shopware.Utils.debug.error("checkout-iap", errorResponse); - this.errorMessage = this.getError(errorResponse); + Shopware.Utils.debug.error('checkout-iap', errorResponse); + this.reason = this.getErrorApiCode(errorResponse); this.state = 'error'; this.trackCheckoutEvent('failed'); }); @@ -162,7 +171,7 @@ export default Shopware.Component.wrapComponentConfig({ } }, - getError(errorResponse: ErrorResponse): string | null { + getErrorApiCode(errorResponse: ErrorResponse): string | null { return errorResponse?.response?.data.errors[0]?.apiCode ?? null; }, @@ -174,7 +183,7 @@ export default Shopware.Component.wrapComponentConfig({ this.store.dismiss(); this.inAppPurchaseCart = null; this.extension = null; - this.errorMessage = null; + this.reason = null; this.state = 'loading'; this.purchase = null; this.variant = null; diff --git a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/sw-in-app-purchase-checkout.html.twig b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/sw-in-app-purchase-checkout.html.twig index 5adbc591..1bacfb53 100644 --- a/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/sw-in-app-purchase-checkout.html.twig +++ b/src/Resources/app/administration/src/module/sw-in-app-purchases/component/sw-in-app-purchase-checkout/sw-in-app-purchase-checkout.html.twig @@ -34,9 +34,9 @@ />