From 4e40f0dfea620c48db129e1b221edab41d29479c Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Sat, 10 Oct 2026 19:03:22 +0300 Subject: [PATCH 01/18] Shop plugin rewrite with Laravel API and improved frontend --- _api_app/app/Shared/Helpers.php | 8 + _api_app/app/Shop/shoppingCart.twig | 47 +- .../Entries/SectionEntriesController.php | 2 +- .../Entries/SectionEntryRenderService.php | 4 +- .../Sections/Entries/shop/_addToCart.twig | 4 +- .../Sections/MessyTemplateRenderService.php | 12 - .../Sections/SectionHeadRenderService.php | 102 +- .../Sites/Sections/SiteSectionsController.php | 4 +- .../app/Sites/Sections/messyTemplate.twig | 3 - _api_app/app/Sites/SitesDataService.php | 2 +- ...dataset__in_the_editor__by_default__.snap" | 298 ++++++ ...dataset__in_the_editor__customised__.snap" | 298 ++++++ ...__dataset__on_the_site__by_default__.snap" | 267 +++++ ...__dataset__on_the_site__customised__.snap" | 274 +++++ .../Feature/SectionHeadShopStylesTest.php | 76 ++ .../Feature/ShopCartRenderServiceTest.php | 111 ++ _api_app/tests/Feature/ShopControllerTest.php | 144 ++- .../Feature/ShopStorefrontControllerTest.php | 970 ++++++++++++++++++ _api_app/tests/Pest.php | 87 ++ editor/copy-twig-templates.mjs | 3 +- editor/src/app/preview/style.service.spec.ts | 54 + editor/src/app/preview/style.service.ts | 26 +- .../render/messy-template-render.service.ts | 1 - .../src/app/render/template-render.service.ts | 8 + editor/src/app/render/twig-templates.ts | 54 +- .../shop/settings/shop-settings.component.ts | 6 + .../app/shop/shop-cart-render.service.spec.ts | 240 +++++ .../src/app/shop/shop-cart-render.service.ts | 842 +++++---------- .../section-head-render.service.spec.ts | 72 ++ .../sections/section-head-render.service.ts | 114 +- engine/_classes/class.bertatemplate.php | 6 - 31 files changed, 3416 insertions(+), 723 deletions(-) create mode 100644 "_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" create mode 100644 "_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" create mode 100644 "_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" create mode 100644 "_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" create mode 100644 _api_app/tests/Feature/SectionHeadShopStylesTest.php create mode 100644 _api_app/tests/Feature/ShopCartRenderServiceTest.php create mode 100644 _api_app/tests/Feature/ShopStorefrontControllerTest.php create mode 100644 editor/src/app/preview/style.service.spec.ts create mode 100644 editor/src/app/shop/shop-cart-render.service.spec.ts create mode 100644 editor/src/app/sites/sections/section-head-render.service.spec.ts diff --git a/_api_app/app/Shared/Helpers.php b/_api_app/app/Shared/Helpers.php index 5a56f25c0..c0c89af10 100644 --- a/_api_app/app/Shared/Helpers.php +++ b/_api_app/app/Shared/Helpers.php @@ -593,6 +593,14 @@ public static function arrayToHtmlAttributes($attributes) return $html; } + /** + * Whether the shop plugin is licensed for the requested domain + */ + public static function isShopEnabled(?string $host): bool + { + return self::isValidDomain($host, config('plugin-Shop.key')); + } + public static function isValidDomain($requestDomain, $domains) { if (empty($requestDomain) || empty($domains)) { diff --git a/_api_app/app/Shop/shoppingCart.twig b/_api_app/app/Shop/shoppingCart.twig index 88b4a5703..5f4906f77 100644 --- a/_api_app/app/Shop/shoppingCart.twig +++ b/_api_app/app/Shop/shoppingCart.twig @@ -13,6 +13,12 @@ + +
@@ -115,7 +121,6 @@
- {{ shippingTotal.content }} @@ -127,8 +132,6 @@
- - {% if isEditMode %} {% if discount.content %}{{ discount.content }}{% else %} discount {% endif %} @@ -157,8 +160,8 @@ {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %}
{% endif %} -
{{ currency }}
+
@@ -185,7 +188,7 @@

-

-

-

-

@@ -275,7 +278,7 @@

-

@@ -323,7 +326,7 @@

-

@@ -368,7 +371,7 @@

-

@@ -427,22 +430,20 @@

{% endif %} - - {% if isPaypalAvailable %}
-
-
{% endif %} {% if isManualTransferAvailable %}
-
- {{ addToBasketLabel }} - + +
diff --git a/_api_app/app/Sites/Sections/MessyTemplateRenderService.php b/_api_app/app/Sites/Sections/MessyTemplateRenderService.php index 4aba9097d..0601a795a 100644 --- a/_api_app/app/Sites/Sections/MessyTemplateRenderService.php +++ b/_api_app/app/Sites/Sections/MessyTemplateRenderService.php @@ -95,7 +95,6 @@ public function getViewData( $isEditMode ); $data['additionalFooterText'] = $this->getAdditionalFooterText($siteSlug, $siteSettings, $user, $isEditMode); - $data['alertMessage'] = $this->getAlertMessage(); if ($isShopAvailable) { $shopSettingsDS = new ShopSettingsDataService($siteSlug); @@ -350,15 +349,4 @@ private function getGridTrigger($request, $siteSlug, $sections, $currentSection, 'link' => $link, ]; } - - private function getAlertMessage() - { - if (! isset($_SESSION['_berta_msg'])) { - return null; - } - $message = $_SESSION['_berta_msg']; - unset($_SESSION['_berta_msg']); - - return $message; - } } diff --git a/_api_app/app/Sites/Sections/SectionHeadRenderService.php b/_api_app/app/Sites/Sections/SectionHeadRenderService.php index f34a1985b..771a401d7 100644 --- a/_api_app/app/Sites/Sections/SectionHeadRenderService.php +++ b/_api_app/app/Sites/Sections/SectionHeadRenderService.php @@ -167,7 +167,12 @@ private function getStyles($siteSlug, $siteSettings, $currentSection, $siteTempl } if ($isShopAvailable) { - $cssFiles[] = "/_plugin_shop/css/shop.css.php?{$cacheBoost}&{$this->version}" . ($siteSlug ? "&site={$siteSlug}" : ''); + $cssFiles[] = "/_plugin_shop/css/shop.css?{$this->version}"; + $inlineCSS .= $this->getShopCSS( + $this->withDefaults($siteTemplateSettings['group_price_item'] ?? [], array_map(fn($setting) => $setting['default'] ?? null, $shopSettingsDS->getRawConfig())), + $this->withDefaults($siteTemplateSettings['entryHeading'] ?? [], $siteTemplatesConfig[$siteSettings['template']['template']]['templateConf']['entryHeading'] ?? []), + $siteTemplateSettings['pageLayout'] ?? [], + ); } } @@ -179,6 +184,97 @@ private function getStyles($siteSlug, $siteSettings, $currentSection, $siteTempl ]; } + /** + * The shop's styles that depend on the site's settings, following the static `shop.css` + * + * @param array $priceItem The shop's price settings + * @param array $entryHeading The template's entry heading settings + * @param array $pageLayout The template's page layout settings + */ + private function getShopCSS(array $priceItem, array $entryHeading, array $pageLayout): string + { + $isResponsive = ($pageLayout['responsive'] ?? '') == 'yes'; + $isCentered = ($pageLayout['centeredContents'] ?? '') == 'yes'; + + $css = $this->getCSSRule('#pageEntries .cartPrice', [ + 'color' => $priceItem['priceItemFontcolor'] ?? '', + 'font-family' => $this->getFontFamily($priceItem['priceItemgoogleFont'] ?? '', $priceItem['priceItemfontFamily'] ?? ''), + 'font-size' => $priceItem['priceItemfontSize'] ?? '', + 'font-weight' => $priceItem['priceItemfontWeight'] ?? '', + 'font-style' => $priceItem['priceItemfontStyle'] ?? '', + 'font-variant' => $priceItem['priceItemfontVariant'] ?? '', + 'line-height' => $priceItem['priceItemlineHeight'] ?? '', + ]); + $css .= $this->getCSSRule('#shoppingCartTitle, #shoppingCartEmpty', [ + 'font-family' => $this->getFontFamily($entryHeading['googleFont'] ?? '', $entryHeading['fontFamily'] ?? ''), + 'font-weight' => $entryHeading['fontWeight'] ?? '', + 'font-style' => $entryHeading['fontStyle'] ?? '', + 'font-variant' => $entryHeading['fontVariant'] ?? '', + ]); + + if ($isResponsive) { + $css .= '#shoppingCart { position: relative; float: right; right: inherit; margin: 0 10px 10px 10px; }'; + + if ($isCentered) { + $css .= '#shoppingCart { margin-top: 20px; }'; + $css .= '#pageEntries .addToCart div, #pageEntries .addToCart div.cartPrice, #pageEntries .addToCart button.addToCartButton { float: none; }'; + } + } + + if ($isCentered) { + $css .= '@media (max-width: 767px) {'; + $css .= '.bt-auto-responsive #shoppingCart { margin-top: 20px; }'; + $css .= '.bt-auto-responsive #pageEntries .addToCart div, .bt-auto-responsive #pageEntries .addToCart div.cartPrice, .bt-auto-responsive #pageEntries .addToCart button.addToCartButton { float: none; }'; + $css .= '}'; + } + + return $css; + } + + /** + * Settings with each empty value replaced by its default, the way the site's settings files are read + * + * @param array $values + * @param array $defaults + * @return array + */ + private function withDefaults(array $values, array $defaults): array + { + foreach ($values as $key => $value) { + if (trim((string) $value) === '' && is_string($defaults[$key] ?? null)) { + $values[$key] = $defaults[$key]; + } + } + + return $values; + } + + /** + * A Google font's family name, or else the chosen font family + */ + private function getFontFamily(string $googleFont, string $fontFamily): string + { + return trim($googleFont) !== '' ? explode(':', $googleFont)[0] : $fontFamily; + } + + /** + * @param array $declarations Empty values are left out + */ + private function getCSSRule(string $selector, array $declarations): string + { + $declarations = array_filter(array_map('trim', $declarations), fn($value) => $value !== ''); + + if (empty($declarations)) { + return ''; + } + + return $selector . ' { ' . implode(' ', array_map( + fn($property, $value) => "{$property}: {$value};", + array_keys($declarations), + $declarations, + )) . ' }'; + } + public function getSentryScript($user) { $script = ''; @@ -215,6 +311,7 @@ private function getScripts($siteSlug, $siteSettings, $currentSection, $template 'engineABSRoot' => '/engine/', 'siteABSMainRoot' => '/', 'siteABSRoot' => '/' . (! empty($siteSlug) ? $siteSlug . '/' : ''), + 'apiRoot' => '/' . config('app.api_prefix') . '/', 'template' => '/_templates/' . $siteSettings['template']['template'] . '/', 'site' => $siteSlug, ], @@ -232,9 +329,6 @@ private function getScripts($siteSlug, $siteSettings, $currentSection, $template } if ($templateName == 'messy') { - // @todo check this case - // { if ($berta.section.type == 'shopping_cart' && $berta.environment == 'engine') || $berta.section.type != 'shopping_cart' } - $scriptFiles[] = '/_templates/' . $siteSettings['template']['template'] . "/mess.js?{$this->version}"; $scriptFiles[] = '/_templates/' . $siteSettings['template']['template'] . "/masonry.js?{$this->version}"; diff --git a/_api_app/app/Sites/Sections/SiteSectionsController.php b/_api_app/app/Sites/Sections/SiteSectionsController.php index 1502266f0..eab3b6314 100644 --- a/_api_app/app/Sites/Sections/SiteSectionsController.php +++ b/_api_app/app/Sites/Sections/SiteSectionsController.php @@ -252,7 +252,7 @@ public function renderHead(Request $request, $site = '') $siteTemplatesConfig = $siteTemplatesConfigService->getDefaults(); $user = new UserModel; - $isShopAvailable = Helpers::isValidDomain($request->getHost(), config('plugin-Shop.key')); + $isShopAvailable = Helpers::isShopEnabled($request->getHost()); $isEditMode = true; $isPreviewMode = false; $storageService = new Storage($site, $isPreviewMode); @@ -374,7 +374,7 @@ public function renderTemplate(Request $request, $siteSlug = '') $user = new UserModel; - $isShopAvailable = Helpers::isValidDomain($request->getHost(), config('plugin-Shop.key')); + $isShopAvailable = Helpers::isShopEnabled($request->getHost()); $isEditMode = false; $isPreviewMode = false; $storageService = new Storage($siteSlug, $isPreviewMode); diff --git a/_api_app/app/Sites/Sections/messyTemplate.twig b/_api_app/app/Sites/Sections/messyTemplate.twig index 1abc35489..0540c8ed4 100644 --- a/_api_app/app/Sites/Sections/messyTemplate.twig +++ b/_api_app/app/Sites/Sections/messyTemplate.twig @@ -77,8 +77,5 @@ {{ sectionFooter|raw }}
{% endif %} - {% if alertMessage %} - - {% endif %} diff --git a/_api_app/app/Sites/SitesDataService.php b/_api_app/app/Sites/SitesDataService.php index f5f1b7090..967c76e7d 100644 --- a/_api_app/app/Sites/SitesDataService.php +++ b/_api_app/app/Sites/SitesDataService.php @@ -183,7 +183,7 @@ public function create(Request $request, ?string $cloneFrom = null) $site['mediaUrl'] = $sitesDS->MEDIA_URL; $site['order'] = count($sites) - 1; - if (Helpers::isValidDomain($request->getHost(), config('plugin-Shop.key'))) { + if (Helpers::isShopEnabled($request->getHost())) { $clientsDataService = new ShopClientsDataService($name); $clientsDataService->TruncateClients(); $ordersDataService = new ShopOrdersDataService($name); diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" new file mode 100644 index 000000000..921c19bb7 --- /dev/null +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" @@ -0,0 +1,298 @@ +
+
+
+ shopping cart is empty +
+
+ +
Cart
+ +
+
+ shopping cart is expired +
+
+ + + +
+
+
+
+  title 
+
+ +
+
+
+
+  quantity 
+
+
+
+  price 
+
+
+
+  sum 
+ (EUR) +
+
+
+
+ +
+
+
+
+
+
+ -
+
+
+
+
+
+
+ +
+
+
+
+  Promo code 
+ + +
+  OK 
+
+
+
+ +
+
+
+ shipping to +
+ + +
+
+
+ +
+
+
+ + Shipping + +
EUR
+
+
+
+ +
+
+
+
+  discount 
+ +
+
+
+ +
+
+
+
+  total 
+ +
+  incl vat 
+
EUR
+ +
+
+
+
+ +
+
+
+ +
+

+ Billing address +

+ +
+

+

+  legal person 
+

+ +

+

+  Company 
+ +

+ +

+

+  Company reg. no. 
+ +

+ +

+

+  Legal address 
+ +

+ +

+

+  name surname 
+ +

+ +

+

+  address 
+ +

+ +

+

+  phone number 
+ +

+ +

+

+  email 
+ +

+ +

+

+  comments 
+ +

+ +
+  deliver to billing_address 
+
+
+ +
+

+  shipping address 

+ +
+

+

+  name surname 
+ +

+ +

+

+  address 
+ +

+ +

+

+  phone number 
+ +

+ +

+

+  email 
+ +

+ +

+

+  comments 
+ +

+
+
+ +
+
+  receive news and updates 
+
+
+
+ +
+
+

+ Choose payment method +

+ +
+ +

+  payment description 

+ +
+ +
+
+ +
+ +
+ +
+
+ +
+

+  checkout description 

+ +

total EUR

+ + +
+  Please fill in all required fields 
+ +
+  checkout and pay 
+ +
+  return to store 
+ + + +
+
+
+
+
diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" new file mode 100644 index 000000000..fe3c2b7c8 --- /dev/null +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" @@ -0,0 +1,298 @@ +
+
+
+ Custom cartTextEmpty +
+
+ +
Cart
+ +
+
+ Custom cartTextExpired +
+
+ + + +
+
+
+
+ Custom cartTextTitle
+
+ +
+
+
+
+ Custom cartTextQuantity
+
+
+
+ Custom cartTextPrice
+
+
+
+ Custom cartTextSum
+ (EUR) +
+
+
+
+ +
+
+
+
+
+
+ -
+
+
+
+
+
+
+ +
+
+
+
+ Custom cartTextPromoCode
+ + +
+ Custom cartTextPromoBtn
+
+
+
+ +
+
+
+ Custom cartTextShippingTo +
+ + +
+
+
+ +
+
+
+ + Custom shippingTotalText + +
EUR
+
+
+
+ +
+
+
+
+ Custom cartTextDiscount
+ +
+
+
+ +
+
+
+
+ Custom cartTextTotal
+ +
+ Custom cartTextInclVat
+
EUR
+ +
+
+
+
+ +
+
+
+ +
+

+ Custom cartTextBillAddr +

+ +
+

+

+ Custom cartTextLegalPerson
+

+ +

+

+ Custom cartTextCompany
+ +

+ +

+

+ Custom cartTextCompanyRegNo
+ +

+ +

+

+ Custom cartTextLegalAddress
+ +

+ +

+

+ Custom cartTextNameSurname
+ +

+ +

+

+ Custom cartTextAddress
+ +

+ +

+

+ Custom cartTextPhoneNumber
+ +

+ +

+

+ Custom cartTextEmail
+ +

+ +

+

+ Custom cartTextComments
+ +

+ +
+ Custom cartTextDeliverBilling
+
+
+ +
+

+ Custom cartTextShipAddr

+ +
+

+

+ Custom cartTextNameSurname
+ +

+ +

+

+ Custom cartTextAddress
+ +

+ +

+

+ Custom cartTextPhoneNumber
+ +

+ +

+

+ Custom cartTextEmail
+ +

+ +

+

+ Custom cartTextComments
+ +

+
+
+ +
+
+ Custom cartTextReceiveNews
+
+
+
+ +
+
+

+ Custom cartTextCPM +

+ +
+ +

+ Custom cartTextPaymentDescription

+ +
+ +
+
+ +
+ +
+ +
+
+ +
+

+ Custom cartTextCheckoutDescription

+ +

Custom cartTextTotal EUR

+ + +
+ Custom cartTextFillRequiredFields
+ +
+ Custom cartTextCheckoutButton
+ +
+ Custom cartTextReturnStore
+ + + +
+
+
+
+
diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" new file mode 100644 index 000000000..b0f9f03fd --- /dev/null +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" @@ -0,0 +1,267 @@ +
+ + +
Cart
+ + + + + +
+
+
+ title +
+ +
+
+
+ quantity +
+
+ price +
+
+ sum + (EUR) +
+
+
+
+ +
+
+
+
+
+
+ -
+
+
+
+
+
+
+ +
+
+
+ promo code + + + + +
+
+ +
+
+
+ shipping to +
+ + +
+
+
+ +
+
+
+ + Shipping + +
EUR
+
+
+
+ + + +
+
+
+ total + +
EUR
+ +
+
+
+
+ +
+
+
+ +
+

+ Billing address +

+ +
+

+ +

+ + + + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ +

+
+
+ + + +
+
+ +
+
+

+ Choose payment method +

+ +
+ + + +
+ +
+
+ +
+ +

total EUR

+ + +

Please fill in all required fields

+ +

checkout and pay

+ +

return to store

+ + +
+
+
+
+
diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" new file mode 100644 index 000000000..2e956a839 --- /dev/null +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" @@ -0,0 +1,274 @@ +
+ + +
Cart
+ + + + + +
+
+
+ Custom cartTextTitle +
+ +
+
+
+ Custom cartTextQuantity +
+
+ Custom cartTextPrice +
+
+ Custom cartTextSum + (EUR) +
+
+
+
+ +
+
+
+
+
+
+ -
+
+
+
+
+
+
+ +
+
+
+ Custom cartTextPromoCode + + + + +
+
+ +
+
+
+ Custom cartTextShippingTo +
+ + +
+
+
+ +
+
+
+ + Custom shippingTotalText + +
EUR
+
+
+
+ + + +
+
+
+ Custom cartTextTotal + +
EUR
+ +
+
+
+
+ +
+
+
+ +
+

+ Custom cartTextBillAddr +

+ +
+

+ +

+ + + + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ +

+
+
+ + + +
+   +
+
+
+ +
+
+

+ Custom cartTextCPM +

+ +
+ +

+ Custom cartTextPaymentDescription

+ + +
+ +
+
+ +
+

+ Custom cartTextCheckoutDescription

+ +

Custom cartTextTotal EUR

+ + +

Custom cartTextFillRequiredFields

+ +

Custom cartTextCheckoutButton

+ +

Custom cartTextReturnStore

+ + +
+
+
+
+
diff --git a/_api_app/tests/Feature/SectionHeadShopStylesTest.php b/_api_app/tests/Feature/SectionHeadShopStylesTest.php new file mode 100644 index 000000000..1f09ca1fe --- /dev/null +++ b/_api_app/tests/Feature/SectionHeadShopStylesTest.php @@ -0,0 +1,76 @@ +bertaRoot = setUpShopTestRoot(); + // The section renderers read the site owner's account + File::ensureDirectoryExists($this->bertaRoot . '/engine/config'); + File::put($this->bertaRoot . '/engine/config/inc.conf.php', "bertaRoot}/engine/inc.hosting.php"); + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + actingAs(new User); +}); + +afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } +}); + +/** + * @param array> $settings + */ +function writeMessySettings(string $bertaRoot, array $settings): void +{ + $groups = ''; + foreach ($settings as $group => $values) { + $groups .= "<{$group}>" . implode('', array_map( + fn ($name, $value) => "<{$name}>", + array_keys($values), + $values, + )) . ""; + } + + File::put($bertaRoot . '/storage/settings.messy.xml', "{$groups}"); +} + +it('links the static shop styles and writes the price and cart heading fonts', function () { + writeShopSettings($this->bertaRoot, ['priceItemFontcolor' => '#ff0000', 'priceItemgoogleFont' => 'Roboto:400,700', 'priceItemfontSize' => '']); + writeMessySettings($this->bertaRoot, ['entryHeading' => ['fontWeight' => 'bold', 'fontStyle' => '']]); + + get('http://shop.test/v1/sites/sections/render-head') + ->assertOk() + ->assertSee('/_plugin_shop/css/shop.css?', false) + ->assertDontSee('shop.css.php', false) + // An empty setting shows its default, as the site's settings files read it + ->assertSee('#pageEntries .cartPrice { color: #ff0000; font-family: Roboto; font-size: 12px; font-weight: bold; font-style: normal; font-variant: normal; line-height: 14px; }', false) + ->assertSee('#shoppingCartTitle, #shoppingCartEmpty { font-family: Arial, sans-serif; font-weight: bold; font-style: normal; font-variant: normal; }', false) + ->assertDontSee('#shoppingCart { position: relative;', false); +}); + +it('writes the cart link and price layout of a responsive, centered page', function () { + writeMessySettings($this->bertaRoot, ['pageLayout' => ['responsive' => 'yes', 'centeredContents' => 'yes']]); + + get('http://shop.test/v1/sites/sections/render-head') + ->assertOk() + ->assertSee('#shoppingCart { position: relative; float: right; right: inherit; margin: 0 10px 10px 10px; }#shoppingCart { margin-top: 20px; }', false) + ->assertSee('@media (max-width: 767px) {.bt-auto-responsive #shoppingCart { margin-top: 20px; }', false); +}); + +it('leaves the shop styles out on a domain without a shop key', function () { + get('http://other.test/v1/sites/sections/render-head') + ->assertOk() + ->assertDontSee('/_plugin_shop/css/', false) + ->assertDontSee('.cartPrice', false); +}); diff --git a/_api_app/tests/Feature/ShopCartRenderServiceTest.php b/_api_app/tests/Feature/ShopCartRenderServiceTest.php new file mode 100644 index 000000000..c9df61b58 --- /dev/null +++ b/_api_app/tests/Feature/ShopCartRenderServiceTest.php @@ -0,0 +1,111 @@ +bertaRoot = setUpShopTestRoot(); +}); + +afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } +}); + +/** + * The shopping cart section as the site shows it to visitors + * + * @param array $shop + */ +function renderSiteCart(string $bertaRoot, array $shop = []): string +{ + writeShopSettings($bertaRoot, $shop); + + return (string) (new ShopCartRenderService)->render( + '', + ['template' => ['template' => 'messy-0.4.2']], + (new ShopSettingsDataService(''))->get(), + [], + Request::create('http://shop.test/cart'), + [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], + 'cart', + false, + ); +} + +it('preselects bank transfer when PayPal is not set up', function () { + $html = renderSiteCart($this->bertaRoot); + + expect($html) + ->not->toContain('id="pm_paypal"') + ->not->toContain('id="pm_creditcard"') + ->toMatch('/id="pm_bank_transfer" checked="checked"/'); +}); + +it('preselects card payment when PayPal is set up', function () { + $html = renderSiteCart($this->bertaRoot, [ + 'paypalUserName' => 'seller_api1.berta.test', + 'paypalUserPassword' => 'secret', + 'paypalSignature' => 'signature', + ]); + + expect($html) + ->toContain('id="pm_paypal"') + ->toMatch('/id="pm_creditcard" checked="checked"/') + ->not->toMatch('/id="pm_bank_transfer"[^>]*checked/'); +}); + +it('escapes shipping region names once', function () { + writeShopSettings($this->bertaRoot); + + $html = (string) (new ShopCartRenderService)->render( + '', + ['template' => ['template' => 'messy-0.4.2']], + (new ShopSettingsDataService(''))->get(), + [['id' => 1, 'name' => 'Rock & Roll', 'vat' => 0]], + Request::create('http://shop.test/cart'), + [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], + 'cart', + false, + ); + + expect($html)->toContain(''); +}); + +describe('editable texts', function () { + $customTexts = collect([ + 'cartManualTransfer', 'cartTextAddress', 'cartTextBillAddr', 'cartTextCPM', 'cartTextCheckoutButton', + 'cartTextCheckoutDescription', 'cartTextComments', 'cartTextCompany', 'cartTextCompanyRegNo', + 'cartTextDeliverBilling', 'cartTextDiscount', 'cartTextEmail', 'cartTextEmpty', 'cartTextExpired', + 'cartTextFillRequiredFields', 'cartTextInclVat', 'cartTextLegalAddress', 'cartTextLegalPerson', + 'cartTextNameSurname', 'cartTextPaymentDescription', 'cartTextPhoneNumber', 'cartTextPrice', + 'cartTextPromoBtn', 'cartTextPromoCode', 'cartTextQuantity', 'cartTextReceiveNews', 'cartTextReturnStore', + 'cartTextShipAddr', 'cartTextShippingTo', 'cartTextSum', 'cartTextTerms', 'cartTextTitle', 'cartTextTotal', + 'shippingTotalText', + ])->mapWithKeys(fn ($setting) => [$setting => "Custom {$setting}"])->all(); + + it('renders the cart texts', function (bool $isEditMode, array $siteTexts) { + $html = (string) (new ShopCartRenderService)->render( + 'blog', + ['template' => ['template' => 'messy-0.4.2'], 'siteTexts' => $siteTexts], + (new ShopSettingsDataService(''))->get(), + [['id' => 1, 'name' => 'Europe', 'vat' => 21]], + Request::create('http://shop.test/cart'), + [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart']]], + 'cart', + $isEditMode, + ); + + expect($html)->toMatchSnapshot(); + })->with([ + 'on the site, by default' => [false, []], + 'on the site, customised' => [false, $customTexts], + 'in the editor, by default' => [true, []], + 'in the editor, customised' => [true, $customTexts], + ]); +}); diff --git a/_api_app/tests/Feature/ShopControllerTest.php b/_api_app/tests/Feature/ShopControllerTest.php index 9613f416f..deef090be 100644 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ b/_api_app/tests/Feature/ShopControllerTest.php @@ -3,16 +3,20 @@ use App\Http\Middleware\Authenticate; use App\Http\Middleware\SetupMiddleware; use App\Models\User; -use App\Plugins\Shop\ShopController; +use App\Plugins\Shop\ShopSettingsDataService; +use App\Plugins\Shop\ShopShippingRegionsDataService; +use App\Sites\Settings\SiteSettingsDataService; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; use function Pest\Laravel\actingAs; use function Pest\Laravel\getJson; +use function Pest\Laravel\patchJson; +use function Pest\Laravel\postJson; use function Pest\Laravel\withoutMiddleware; -$pluginInstalled = class_exists(ShopController::class); - beforeEach(function () { + skipWithoutShopPlugin(); // SetupMiddleware reconciles the real .env file on every request. withoutMiddleware(SetupMiddleware::class); config(['plugin-Shop.key' => 'shop.test']); @@ -24,7 +28,7 @@ $middleware = Route::getRoutes()->getByName('settings-config')->gatherMiddleware(); expect($middleware)->toContain(Authenticate::class); -})->skip(! $pluginInstalled, 'Shop plugin not installed'); +}); it('returns 403 for the settings config on a domain without a shop key', function () { actingAs(new User); @@ -32,7 +36,31 @@ getJson('http://other.test' . route('settings-config', [], false)) ->assertForbidden() ->assertJsonPath('message', 'Shop not available!'); -})->skip(! $pluginInstalled, 'Shop plugin not installed'); +}); + +it('returns 403 from every shop admin endpoint on a domain without a shop key', function (string $method, string $path) { + actingAs(new User); + + $this->json($method, 'http://other.test' . route('shop-state', [], false) . $path) + ->assertForbidden() + ->assertJsonPath('message', 'Shop not available!'); +})->with([ + 'state' => ['GET', ''], + 'products' => ['GET', '/products'], + 'product update' => ['PATCH', '/products'], + 'orders' => ['GET', '/orders'], + 'regional costs' => ['GET', '/regional-costs'], + 'region add' => ['POST', '/region'], + 'region update' => ['PATCH', '/region'], + 'region delete' => ['DELETE', '/region/0/1'], + 'regional cost add' => ['POST', '/regional-costs'], + 'regional cost update' => ['PATCH', '/regional-costs'], + 'regional cost delete' => ['DELETE', '/regional-costs/0/1'], + 'settings' => ['GET', '/settings'], + 'settings update' => ['PATCH', '/settings'], + 'settings upload' => ['POST', '/settings/upload'], + 'settings config' => ['GET', '/settings-config'], +]); it('returns the shop settings config grouped by section', function () { actingAs(new User); @@ -41,4 +69,108 @@ ->assertOk() ->assertJsonPath('data.group_config._.title', 'Configuration values') ->assertJsonPath('data.group_config.currency.default', 'EUR'); -})->skip(! $pluginInstalled, 'Shop plugin not installed'); +}); + +it('translates the shop settings config', function () { + $bertaRoot = setUpShopTestRoot(); + actingAs(new User); + + try { + expect((new ShopSettingsDataService(''))->getConfig('lv')) + ->group_config->_->title->toBe('Konfigurācijas vērtības') + ->group_config->currency->default->toBe('EUR'); + } finally { + File::deleteDirectory($bertaRoot); + } +}); + +it('returns the explanation of each PayPal credentials group', function () { + actingAs(new User); + + getJson('http://shop.test' . route('settings-config', [], false)) + ->assertOk() + ->assertJsonPath('data.group_paypal_rest._.title', 'PayPal REST app credentials (recommended)') + ->assertJsonPath('data.group_paypal_rest._.description', fn ($description) => str_contains($description, 'https://developer.paypal.com/dashboard/applications/live')) + ->assertJsonPath('data.group_paypal_classic._.description', fn ($description) => str_contains($description, 'https://developer.paypal.com/api/nvp-soap/apiCredentials/')) + ->assertJsonMissingPath('data.group_config._.description'); +}); + +describe('regions', function () { + beforeEach(function () { + $this->bertaRoot = setUpShopTestRoot(); + actingAs(new User); + }); + + afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } + }); + + it('stores a decimal VAT rate typed with a comma', function () { + postJson('http://shop.test' . route('regions', [], false), ['site' => '', 'data' => ['name' => 'Finland', 'vat' => '25,5']]) + ->assertOk() + ->assertJsonPath('data.data.vat', 25.5); + + expect((new ShopShippingRegionsDataService(''))->query()->value('vat'))->toEqual(25.5); + }); + + it('updates the VAT rate of a region', function () { + (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); + + patchJson('http://shop.test' . route('regions', [], false), ['path' => '/1/vat', 'value' => '13.5']) + ->assertOk() + ->assertJsonPath('data.value', 13.5); + + expect((new ShopShippingRegionsDataService(''))->query()->value('vat'))->toEqual(13.5); + }); + + it('returns 400 for a VAT rate that is not a number from 0 to 100', function (string $vat) { + (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); + + patchJson('http://shop.test' . route('regions', [], false), ['path' => '/1/vat', 'value' => $vat]) + ->assertStatus(400) + ->assertJsonPath('message', 'VAT must be a number from 0 to 100'); + + expect((new ShopShippingRegionsDataService(''))->query()->value('vat'))->toEqual(24); + })->with(['abc', '101', '-1']); + + it('stores a renamed region as typed', function () { + (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); + + patchJson('http://shop.test' . route('regions', [], false), ['path' => '/1/name', 'value' => 'Rock & "Roll"']) + ->assertOk() + ->assertJsonPath('data.value', 'Rock & "Roll"'); + + expect((new ShopShippingRegionsDataService(''))->query()->value('name'))->toBe('Rock & "Roll"'); + }); + + it('numbers new regions and their costs after the existing ones', function () { + (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); + (new ShopShippingRegionsDataService(''))->query()->where('id', 1)->update(['id' => 7]); + + postJson('http://shop.test' . route('regions', [], false), ['site' => '', 'data' => ['name' => 'Estonia', 'vat' => '22']]) + ->assertOk() + ->assertJsonPath('data.data.id', 8); + + postJson('http://shop.test' . route('regional-costs', [], false), ['site' => '', 'data' => ['id_region' => 8, 'weight' => '500', 'price' => '4.5']]) + ->assertOk() + ->assertJsonPath('data.data.id', 1); + postJson('http://shop.test' . route('regional-costs', [], false), ['site' => '', 'data' => ['id_region' => 8, 'weight' => '1000', 'price' => '6']]) + ->assertOk() + ->assertJsonPath('data.data.id', 2); + }); + + it('unescapes the region names an older shop version stored HTML-escaped', function () { + writeShopSettings($this->bertaRoot, ['version' => '4']); + $database = new SQLite3($this->bertaRoot . '/storage/shop-db.sqlite'); + $database->exec('CREATE TABLE "shipping_region" ("id" integer not null, "name" varchar not null, "vat" integer not null, primary key ("id"))'); + $database->exec("INSERT INTO shipping_region (name, vat) VALUES ('Rock & "Roll"', 0), ('Plain', 0)"); + $database->close(); + + $regions = new ShopShippingRegionsDataService(''); + + expect($regions->query()->orderBy('id')->pluck('name')->all())->toBe(['Rock & "Roll"', 'Plain']) + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(5); + }); +}); diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php new file mode 100644 index 000000000..dd5513166 --- /dev/null +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -0,0 +1,970 @@ +bertaRoot = setUpShopTestRoot(); + + File::put($this->bertaRoot . '/storage/sections.xml', '' + . '
<![CDATA[Shop]]>
' + . '
<![CDATA[Cart]]>
' + . '
'); + File::put($this->bertaRoot . '/storage/blog.shop.xml', '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''); + + (new ShopShippingRegionsDataService(''))->addRegion('Europe', 21); + (new ShopShippingCostsDataService(''))->addRegionCost(1, 500, 5); + (new ShopShippingCostsDataService(''))->addRegionCost(1, 2000, 10); + + // SetupMiddleware reconciles the real .env file on every request. + withoutMiddleware(SetupMiddleware::class); + Http::preventStrayRequests(); +}); + +afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } +}); + +function nvpSettings(): array +{ + return ['paypalUserName' => 'seller_api1.berta.test', 'paypalUserPassword' => 'secret', 'paypalSignature' => 'signature', 'paypalCurrencyCode' => 'EUR']; +} + +function restSettings(): array +{ + return ['paypalClientId' => 'client-id', 'paypalClientSecret' => 'client-secret', 'paypalCurrencyCode' => 'EUR']; +} + +function storefrontUrl(string $path, array $query = []): string +{ + return 'http://shop.test/v1/plugin/shop/storefront/' . $path . ($query ? '?' . http_build_query($query) : ''); +} + +function shopDb(): Connection +{ + return (new ShopClientsDataService(''))->getConnection(); +} + +/** + * A site visitor whose shop_client cookie holds the basket token + */ +function asShopCustomer(?string $token, string $site = ''): TestCase +{ + return withCredentials()->withUnencryptedCookie(ShopBasketService::clientCookie($site), (string) $token); +} + +function addToShopBasket(string $uniqid, ?string $attribute = null, ?string $token = null): TestResponse +{ + return asShopCustomer($token) + ->postJson(storefrontUrl('basket'), ['uniqid' => $uniqid, 'attribute' => $attribute]); +} + +/** + * Fills a new basket and returns its cookie token + * + * @param list $items + */ +function fillShopBasket(array $items): string +{ + $token = null; + foreach ($items as $item) { + $token = addToShopBasket($item[0], $item[1] ?? null, $token) + ->assertOk() + ->getCookie(ShopBasketService::clientCookie(''), false) + ->getValue(); + } + + return $token; +} + +function shopCheckout(string $token, array $form = [], string $site = ''): TestResponse +{ + return asShopCustomer($token, $site) + ->postJson(storefrontUrl('checkout', array_filter(['site' => $site])), array_merge([ + 'name' => 'Ann Buyer', + 'phone' => '+371 20000000', + 'address' => 'Brivibas 1, Riga', + 'email' => 'ann@buyer.test', + 'notes' => '', + 'legal_person' => false, + 'deliver_to_billing' => true, + 'send_news' => false, + 'promo_code' => '', + 'region' => 1, + 'payment_method' => 'bank_transfer', + ], $form)); +} + +function fakeNvp(string $captureAck = 'Success'): void +{ + Http::fake(['api-3t.paypal.com/nvp' => fn (HttpRequest $request) => match ($request['METHOD']) { + 'SetExpressCheckout' => Http::response('ACK=Success&TOKEN=' . SHOP_NVP_TOKEN), + 'DoExpressCheckoutPayment' => Http::response("ACK={$captureAck}&PAYMENTINFO_0_TRANSACTIONID=8AB12345CD678901E"), + }]); +} + +/** + * @param array|null $order PayPal's answer to creating an order, by default a redirect order's + */ +function fakeRest(?Closure $capture = null, ?array $order = null): void +{ + Http::fake([ + 'api-m.paypal.com/v1/oauth2/token' => Http::response(['access_token' => 'A21AA', 'expires_in' => 32400]), + 'api-m.paypal.com/v2/checkout/orders' => Http::response($order ?? [ + 'id' => SHOP_REST_TOKEN, + 'status' => 'PAYER_ACTION_REQUIRED', + 'links' => [['rel' => 'payer-action', 'href' => 'https://www.paypal.com/checkoutnow?token=' . SHOP_REST_TOKEN]], + ]), + 'api-m.paypal.com/v2/checkout/orders/*/capture' => $capture ?? Http::response(paidRestOrder()), + ]); +} + +function paidRestOrder(): array +{ + return [ + 'id' => SHOP_REST_TOKEN, + 'status' => 'COMPLETED', + 'purchase_units' => [['payments' => ['captures' => [['id' => '3C679366HH908993F', 'status' => 'COMPLETED']]]]], + ]; +} + +function returnFromPaypal(string $token): TestResponse +{ + return get(storefrontUrl('paypal/return', ['token' => $token, 'PayerID' => 'PAYER1'])); +} + +function capturesSent(): int +{ + return Http::recorded(fn (HttpRequest $request) => str_ends_with($request->url(), '/capture') + || ($request->data()['METHOD'] ?? null) === 'DoExpressCheckoutPayment')->count(); +} + +it('returns 403 on a domain without the shop key', function () { + postJson('http://other.test/v1/plugin/shop/storefront/stock', ['uniqids' => [SHOP_CHAIR]]) + ->assertForbidden() + ->assertJsonPath('message', 'Shop not available!'); +}); + +it('returns 404 for a site that does not exist', function () { + postJson(storefrontUrl('stock', ['site' => '../../elsewhere']), ['uniqids' => [SHOP_CHAIR]]) + ->assertNotFound(); + + expect(File::exists($this->bertaRoot . '/elsewhere'))->toBeFalse(); +}); + +describe('stock', function () { + it('returns the stock of each entry with all its variants together', function () { + (new ShopProductsDataService(''))->addProduct('T-shirt S', SHOP_TSHIRT, 3); + + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR, SHOP_TSHIRT, 'draft0000001']]) + ->assertOk() + ->assertExactJson(['message' => '', 'data' => [ + ['uniqid' => SHOP_CHAIR, 'instock' => 1], + ['uniqid' => SHOP_TSHIRT, 'instock' => 4], + ]]); + }); + + it('puts the reservations of abandoned baskets back in stock', function () { + fillShopBasket([[SHOP_CHAIR]]); + travel(31)->minutes(); + + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]) + ->assertJsonPath('data.0.instock', 1); + + expect(shopDb()->table('orders')->count())->toBe(0); + }); +}); + +describe('basket', function () { + it('reserves a product at the price of its entry, not the posted one', function () { + $response = asShopCustomer(null) + ->postJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR, 'price' => 0.01, 'weight' => 0]) + ->assertOk() + ->assertJsonPath('data.added', true); + + expect($response->getCookie(ShopBasketService::clientCookie(''), false)->isHttpOnly())->toBeTrue(); + expect(shopDb()->table('orders')->first())->price->toEqual(100)->weight->toEqual(400); + expect(shopDb()->table('product')->where('name', 'Chair')->first())->instock->toEqual(0)->reservation->toEqual(1); + }); + + it('reserves the variant of the chosen cart attribute', function () { + fillShopBasket([[SHOP_TSHIRT, 'M']]); + + expect(shopDb()->table('product')->where('uniqid', SHOP_TSHIRT)->pluck('name')->all())->toBe(['T-shirt M']); + }); + + it('returns 404 for a cart attribute the entry does not have', function () { + addToShopBasket(SHOP_TSHIRT, 'XL')->assertNotFound(); + addToShopBasket(SHOP_CHAIR, 'XL')->assertNotFound(); + }); + + it('returns 404 for an entry without a price', function () { + addToShopBasket('draft0000001')->assertNotFound(); + }); + + it('returns 409 when the variant is out of stock', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + + addToShopBasket(SHOP_CHAIR, null, $token) + ->assertConflict() + ->assertJsonPath('data.added', false); + + expect(shopDb()->table('orders')->count())->toBe(1); + }); + + it('puts a removed unit back in stock', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + + asShopCustomer($token) + ->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR]) + ->assertOk(); + + expect(shopDb()->table('orders')->count())->toBe(0); + expect(shopDb()->table('product')->where('name', 'Chair')->first())->instock->toEqual(1)->reservation->toEqual(0); + }); + + it('keeps the basket for its lifetime from the last product added', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(25)->minutes(); + addToShopBasket(SHOP_TSHIRT, 'S', $token)->assertOk(); + travel(25)->minutes(); + + shopCheckout($token)->assertOk(); + }); + + it('keeps the basket for its lifetime from the last product removed', function () { + $token = fillShopBasket([[SHOP_CHAIR], [SHOP_TSHIRT, 'S']]); + travel(25)->minutes(); + asShopCustomer($token)->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_TSHIRT, 'attribute' => 'S'])->assertOk(); + travel(25)->minutes(); + + shopCheckout($token)->assertOk(); + }); + + it('returns 410 for a product added to an expired basket, then starts a new basket', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(31)->minutes(); + + addToShopBasket(SHOP_TSHIRT, 'S', $token) + ->assertGone() + ->assertJsonPath('data.expired', true); + + expect(shopDb()->table('client')->value('session_id'))->toBe('timeout') + ->and(shopDb()->table('orders')->count())->toBe(0) + ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); + + $newToken = addToShopBasket(SHOP_TSHIRT, 'S', $token) + ->assertOk() + ->getCookie(ShopBasketService::clientCookie(''), false) + ->getValue(); + + expect($newToken)->not->toBe($token); + shopCheckout($newToken)->assertOk(); + }); + + it('returns 410 for a product removed from an expired basket', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(31)->minutes(); + + asShopCustomer($token) + ->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR]) + ->assertGone(); + + expect(shopDb()->table('client')->value('session_id'))->toBe('timeout'); + }); +}); + +describe('basket renewal', function () { + it('keeps the basket for its lifetime from the renewal', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(25)->minutes(); + + asShopCustomer($token) + ->postJson(storefrontUrl('basket/renew')) + ->assertOk() + ->assertJsonPath('data.expires_in', 1800); + + travel(25)->minutes(); + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]); + + expect(shopDb()->table('orders')->count())->toBe(1); + shopCheckout($token)->assertOk(); + }); + + it('lasts the minutes of the shop setting', function () { + writeShopSettings($this->bertaRoot, ['basketExpiresMinutes' => '10']); + $token = fillShopBasket([[SHOP_CHAIR]]); + + asShopCustomer($token) + ->postJson(storefrontUrl('basket/renew')) + ->assertJsonPath('data.expires_in', 600); + + travel(11)->minutes(); + + asShopCustomer($token)->postJson(storefrontUrl('basket/renew'))->assertGone(); + }); + + it('returns 410 and releases the stock of an expired basket', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(31)->minutes(); + + asShopCustomer($token) + ->postJson(storefrontUrl('basket/renew')) + ->assertGone() + ->assertJsonPath('data.expired', true); + + expect(shopDb()->table('client')->value('session_id'))->toBe('timeout') + ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); + }); + + it('returns 410 without a basket', function () { + asShopCustomer('unknown-token')->postJson(storefrontUrl('basket/renew'))->assertGone(); + }); + + it('returns 410 for an empty basket', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + asShopCustomer($token)->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR])->assertOk(); + + asShopCustomer($token)->postJson(storefrontUrl('basket/renew'))->assertGone(); + }); +}); + +it('keeps a separate basket on each site of a multisite', function () { + writeShopSettings($this->bertaRoot, [], 'second'); + File::put($this->bertaRoot . '/storage/-sites/sites.xml', '' + . '<![CDATA[Main]]>' + . '<![CDATA[Second]]>' + . ''); + File::copy($this->bertaRoot . '/storage/sections.xml', $this->bertaRoot . '/storage/-sites/second/sections.xml'); + File::copy($this->bertaRoot . '/storage/blog.shop.xml', $this->bertaRoot . '/storage/-sites/second/blog.shop.xml'); + (new ShopShippingRegionsDataService('second'))->addRegion('Europe', 21); + + // The cookies a browser keeps for the domain, both sites' baskets among them + $cookies = []; + $remember = function (TestResponse $response) use (&$cookies) { + foreach ($response->headers->getCookies() as $cookie) { + $cookies[$cookie->getName()] = $cookie->getValue(); + } + }; + $remember(withCredentials()->withUnencryptedCookies($cookies)->postJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR])->assertOk()); + $remember(withCredentials()->withUnencryptedCookies($cookies)->postJson(storefrontUrl('basket', ['site' => 'second']), ['uniqid' => SHOP_TSHIRT, 'attribute' => 'S'])->assertOk()); + + shopCheckout($cookies[ShopBasketService::clientCookie('')] ?? '')->assertOk(); + shopCheckout($cookies[ShopBasketService::clientCookie('second')] ?? '', [], 'second')->assertOk(); +}); + +it('takes a product that is no longer for sale out of the basket', function (string $blog) { + $token = fillShopBasket([[SHOP_CHAIR], [SHOP_TSHIRT, 'M']]); + File::put($this->bertaRoot . '/storage/blog.shop.xml', $blog); + + asShopCustomer($token) + ->deleteJson(storefrontUrl('basket'), ['uniqid' => SHOP_CHAIR]) + ->assertOk(); + + expect(shopDb()->table('orders')->join('product', 'product.id', '=', 'orders.id_product')->pluck('product.name')->all())->toBe(['T-shirt M']) + ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); +})->with([ + 'without a price' => '', + 'deleted' => '', +]); + +describe('regional costs', function () { + it('returns the shipping price of the price tier above the basket weight', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + + asShopCustomer($token) + ->getJson(storefrontUrl('regional-costs', ['region' => 1])) + ->assertOk() + ->assertJson(['data' => ['shipping' => 5, 'vat' => 21]]); + }); + + it('returns the heaviest tier price for a basket heavier than all tiers', function () { + (new ShopProductsDataService(''))->addProduct('Chair', SHOP_CHAIR, 10); + $token = fillShopBasket(array_fill(0, 6, [SHOP_CHAIR])); + + asShopCustomer($token) + ->getJson(storefrontUrl('regional-costs', ['region' => 1])) + ->assertJson(['data' => ['shipping' => 10]]); + }); + + it('returns a decimal VAT rate', function () { + (new ShopShippingRegionsDataService(''))->saveValueByPath('1/vat', '25,5'); + + getJson(storefrontUrl('regional-costs', ['region' => 1]))->assertJsonPath('data.vat', 25.5); + }); + + it('returns 404 for an unknown region', function () { + getJson(storefrontUrl('regional-costs', ['region' => 99]))->assertNotFound(); + }); +}); + +describe('promo code', function () { + it('returns the discount of the shop promo code', function () { + getJson(storefrontUrl('promo-code', ['code' => 'SAVE10']))->assertJsonPath('data.discount', 10); + }); + + it('returns no discount for a wrong code', function () { + getJson(storefrontUrl('promo-code', ['code' => 'SAVE20']))->assertJsonPath('data.discount', null); + }); +}); + +describe('bank transfer checkout', function () { + it('saves the order, then emails it to the seller and the customer', function () { + Mail::fake(); + $token = fillShopBasket([[SHOP_CHAIR], [SHOP_TSHIRT, 'M']]); + + $response = shopCheckout($token, ['promo_code' => 'SAVE10']) + ->assertOk() + ->assertJsonPath('data.status', 'placed'); + + // 125 - 10% + 10 shipping for 600 g + expect(shopDb()->table('client')->where('session_id', 'checkout')->first()) + ->name->toBe('Ann Buyer') + ->order_id->toBe($response->json('data.order_id')) + ->price_total->toEqual(122.5) + ->shipping->toEqual(10) + ->vat->toEqual(21) + ->paypal->toEqual(0); + expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(2) + ->and(shopDb()->table('product')->sum('reservation'))->toEqual(0) + ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue(); + + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller + && $mail->hasTo('seller@berta.test') + && $mail->totals['vat'] === 21.26 + && $mail->totals['net'] === 101.24); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller && $mail->hasTo('ann@buyer.test')); + }); + + it('takes off at most the price of the products for a promo discount over 100%', function () { + writeShopSettings($this->bertaRoot, ['promoCodeDiscount' => '150']); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['promo_code' => 'SAVE10'])->assertOk(); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->value('price_total'))->toEqual(5); + }); + + it('emails the VAT and the total excluding it at a decimal VAT rate', function () { + Mail::fake(); + (new ShopShippingRegionsDataService(''))->saveValueByPath('1/vat', '25.5'); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + // 100 + 5 shipping, of which 25.5% VAT + expect(shopDb()->table('client')->where('session_id', 'checkout')->value('vat'))->toEqual(25.5); + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) { + $html = $mail->render(); + + return $mail->totals['vat_percent'] === 25.5 + && str_contains($html, 'Total excl. VAT83.67') + && str_contains($html, 'VAT (25.5%)21.33'); + }); + }); + + it('returns 500 and neither saves nor emails an order that fails to save', function () { + Mail::fake(); + Exceptions::fake(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopDb()->statement("CREATE TRIGGER fail_checkout BEFORE UPDATE OF session_id ON client WHEN NEW.session_id = 'checkout' BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END"); + + shopCheckout($token)->assertInternalServerError(); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() + ->and(shopDb()->table('orders')->where('paid', 1)->count())->toBe(0); + Mail::assertNothingSent(); + }); + + it('places the order when sending its emails fails', function () { + Exceptions::fake(); + config(['plugin-Shop.mailer.host' => '127.0.0.1', 'plugin-Shop.mailer.port' => 1]); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token) + ->assertOk() + ->assertJsonPath('data.status', 'placed'); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue(); + Exceptions::assertReported(TransportException::class); + }); + + it('returns 410 and releases the stock of an expired basket', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(31)->minutes(); + + shopCheckout($token)->assertGone()->assertJsonPath('data.expired', true); + + expect(shopDb()->table('client')->value('session_id'))->toBe('timeout') + ->and(shopDb()->table('product')->where('name', 'Chair')->value('instock'))->toEqual(1); + }); + + it('returns 410 without a basket', function () { + shopCheckout('unknown-token')->assertGone(); + }); + + it('rejects a payment method the shop does not accept', function () { + writeShopSettings($this->bertaRoot, ['paymentMethod' => 'paypal', ...nvpSettings()]); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token) + ->assertUnprocessable() + ->assertJsonValidationErrors(['payment_method' => 'The shop does not accept this payment method.']); + }); + + it('requires the shipping address unless delivering to the billing address', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['deliver_to_billing' => false]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['ship_name', 'ship_phone', 'ship_address']); + }); + + it('requires the company details of a legal person', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['legal_person' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['company', 'company_reg_no', 'legal_address']); + }); +}); + +describe('PayPal checkout', function () { + it('rejects PayPal when the shop has no PayPal credentials', function () { + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal'])->assertUnprocessable(); + }); + + it('starts an NVP payment for the server-side total without saving or emailing the order', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal', 'promo_code' => 'SAVE10']) + ->assertOk() + ->assertJsonPath('data.status', 'redirect') + ->assertJsonPath('data.redirect_url', 'https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&useraction=commit&token=' . SHOP_NVP_TOKEN); + + Http::assertSent(fn (HttpRequest $request) => $request['METHOD'] === 'SetExpressCheckout' + && $request['PAYMENTREQUEST_0_AMT'] === '95.00' + && $request['PAYMENTREQUEST_0_ITEMAMT'] === '90.00' + && $request['L_PAYMENTREQUEST_0_AMT1'] === '-10.00' + && (string) $request['NOSHIPPING'] === '1' + && $request['EMAIL'] === 'ann@buyer.test' + && ! isset($request['SOLUTIONTYPE'])); + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() + ->and(shopDb()->table('orders')->where('paid', 1)->count())->toBe(0); + Mail::assertNothingSent(); + }); + + it('starts a REST payment on the guest checkout page for card payments', function () { + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'creditcard']) + ->assertOk() + ->assertJsonPath('data.redirect_url', 'https://www.paypal.com/checkoutnow?token=' . SHOP_REST_TOKEN) + ->assertJsonPath('data.token', SHOP_REST_TOKEN); + + Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') + && $request['purchase_units'][0]['amount']['value'] === '105.00' + && $request['payment_source']['paypal']['experience_context']['landing_page'] === 'GUEST_CHECKOUT' + && $request['payment_source']['paypal']['experience_context']['shipping_preference'] === 'NO_SHIPPING' + && $request['payment_source']['paypal']['email_address'] === 'ann@buyer.test' + && $request['payment_source']['paypal']['name'] === ['given_name' => 'Ann', 'surname' => 'Buyer']); + }); + + it('starts a PayPal order for the buttons without a redirect to PayPal', function () { + writeShopSettings($this->bertaRoot, restSettings()); + // An order without a payment source waits for approval in PayPal's buttons + fakeRest(order: [ + 'id' => SHOP_REST_TOKEN, + 'status' => 'CREATED', + 'links' => [ + ['rel' => 'self', 'href' => 'https://api-m.paypal.com/v2/checkout/orders/' . SHOP_REST_TOKEN], + ['rel' => 'approve', 'href' => 'https://www.paypal.com/checkoutnow?token=' . SHOP_REST_TOKEN], + ['rel' => 'update', 'href' => 'https://api-m.paypal.com/v2/checkout/orders/' . SHOP_REST_TOKEN], + ['rel' => 'capture', 'href' => 'https://api-m.paypal.com/v2/checkout/orders/' . SHOP_REST_TOKEN . '/capture'], + ], + ]); + $token = fillShopBasket([[SHOP_CHAIR]]); + + $orderId = shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true]) + ->assertOk() + ->assertJsonPath('data.token', SHOP_REST_TOKEN) + ->json('data.order_id'); + + // The buttons choose the PayPal login or the card form, PayPal only skips the shipping address + Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') + && $request['purchase_units'][0]['custom_id'] === $orderId + && $request['payment_source']['paypal']['experience_context']['shipping_preference'] === 'NO_SHIPPING' + && ! isset($request['payment_source']['paypal']['experience_context']['landing_page']) + && $request['payment_source']['paypal']['email_address'] === 'ann@buyer.test' + && $request['payment_source']['paypal']['name'] === ['given_name' => 'Ann', 'surname' => 'Buyer']); + }); + + it('fills in the name and email PayPal accepts', function (string $name, string $email, ?array $paypalName, ?string $paypalEmail) { + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal', 'paypal_buttons' => true, 'name' => $name, 'email' => $email])->assertOk(); + + Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') + && ($request['payment_source']['paypal']['name'] ?? null) === $paypalName + && ($request['payment_source']['paypal']['email_address'] ?? null) === $paypalEmail); + })->with([ + 'a first name and surname' => ['Ann Marie Buyer', 'ann@buyer.test', ['given_name' => 'Ann Marie', 'surname' => 'Buyer'], 'ann@buyer.test'], + 'a name and surname split by a comma' => ['Ann, Buyer', 'ann@buyer.test', ['given_name' => 'Ann', 'surname' => 'Buyer'], 'ann@buyer.test'], + 'a single name' => ['Ann', 'ann@buyer.test', null, 'ann@buyer.test'], + 'an email without a domain name' => ['Ann Buyer', 'ann@localhost', ['given_name' => 'Ann', 'surname' => 'Buyer'], null], + ]); + + it('returns 500 and saves nothing when PayPal gives a redirect no approval page', function () { + Exceptions::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(order: ['id' => SHOP_REST_TOKEN, 'status' => 'CREATED', 'links' => []]); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal'])->assertInternalServerError(); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); + Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'PayPal order has no approval link'); + }); + + it('uses REST when the shop has both REST and NVP credentials', function () { + writeShopSettings($this->bertaRoot, [...nvpSettings(), ...restSettings()]); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + Http::assertNotSent(fn (HttpRequest $request) => str_contains($request->url(), 'api-3t.paypal.com')); + }); + + it('keeps the reservations for the basket lifetime from the start of the payment', function () { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + travel(25)->minutes(); + + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + travel(25)->minutes(); + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]); + + expect(shopDb()->table('orders')->count())->toBe(1); + }); +}); + +describe('PayPal return', function () { + it('saves and emails an order once PayPal takes the payment', function (string $api) { + Mail::fake(); + writeShopSettings($this->bertaRoot, $api === 'nvp' ? nvpSettings() : restSettings()); + $api === 'nvp' ? fakeNvp() : fakeRest(); + $paypalToken = $api === 'nvp' ? SHOP_NVP_TOKEN : SHOP_REST_TOKEN; + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + $response = returnFromPaypal($paypalToken)->assertRedirect('http://shop.test/?shop_payment=paid'); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->first())->paypal->toEqual(1)->price_total->toEqual(105); + expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) + ->and(File::exists("{$this->bertaRoot}/_plugin_shop/data/{$paypalToken}"))->toBeTrue() + ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue() + ->and($response->getCookie('shopping_cart', false)->isCleared())->toBeTrue(); + Mail::assertSent(ShopOrderMail::class, 2); + })->with(['nvp', 'rest']); + + it('saves nothing when PayPal declines the payment', function (string $api) { + Mail::fake(); + writeShopSettings($this->bertaRoot, $api === 'nvp' ? nvpSettings() : restSettings()); + $api === 'nvp' + ? fakeNvp('Failure') + : fakeRest(fn () => Http::response(['name' => 'UNPROCESSABLE_ENTITY', 'details' => [['issue' => 'INSTRUMENT_DECLINED']]], 422)); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + returnFromPaypal($api === 'nvp' ? SHOP_NVP_TOKEN : SHOP_REST_TOKEN) + ->assertRedirect('http://shop.test/?shop_payment=failed'); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() + ->and(File::exists("{$this->bertaRoot}/_plugin_shop/data"))->toBeFalse(); + Mail::assertNothingSent(); + })->with(['nvp', 'rest']); + + it('leaves products added after the payment started unpaid', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + addToShopBasket(SHOP_TSHIRT, 'S', $token)->assertOk(); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + expect(shopDb()->table('orders')->join('product', 'product.id', '=', 'orders.id_product')->pluck('orders.paid', 'product.name')->all()) + ->toEqual(['Chair' => 1, 'T-shirt S' => 0]); + }); + + it('captures nothing for a payment it did not start', function () { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + + returnFromPaypal('EC-UNKNOWN')->assertRedirect('http://shop.test/?shop_payment=failed'); + + expect(capturesSent())->toBe(0); + }); + + it('answers a repeated return with the first outcome without capturing again', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + expect(capturesSent())->toBe(1); + Mail::assertSent(ShopOrderMail::class, 2); + }); + + it('reports an unconfirmed payment when PayPal does not answer, and asks again on the next return', function () { + Mail::fake(); + Exceptions::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + $attempts = 0; + fakeRest(function () use (&$attempts) { + return $attempts++ === 0 ? Http::failedConnection() : Http::response(paidRestOrder()); + }); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=unconfirmed'); + expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeFalse(); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue(); + Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/capture') + && $request->header('PayPal-Request-Id') === ['capture-' . SHOP_REST_TOKEN]); + }); + + it('emails a paid order that fails to save', function () { + Mail::fake(); + Exceptions::fake(); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + shopDb()->statement("CREATE TRIGGER fail_checkout BEFORE UPDATE OF session_id ON client WHEN NEW.session_id = 'checkout' BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END"); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->hasTo('seller@berta.test')); + Exceptions::assertReported(fn (QueryException $e) => str_contains($e->getMessage(), 'disk I/O error')); + }); + + it('tells the customer the payment went through when the email fails', function () { + Exceptions::fake(); + config(['plugin-Shop.mailer.host' => '127.0.0.1', 'plugin-Shop.mailer.port' => 1]); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue(); + Exceptions::assertReported(TransportException::class); + }); + + it('keeps the basket when the customer cancels at PayPal', function () { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + get(storefrontUrl('paypal/cancel', ['token' => SHOP_NVP_TOKEN])) + ->assertRedirect('http://shop.test/?shop_payment=cancelled'); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() + ->and(capturesSent())->toBe(0); + }); +}); + +describe('PayPal buttons', function () { + it('returns the client ID, script and currency the buttons start with', function () { + writeShopSettings($this->bertaRoot, [...restSettings(), 'paypalSandbox' => 'yes']); + + getJson(storefrontUrl('paypal/buttons')) + ->assertOk() + ->assertExactJson(['message' => '', 'data' => [ + 'client_id' => 'client-id', + 'sdk_url' => 'https://www.sandbox.paypal.com/web-sdk/v6/core', + 'currency' => 'EUR', + ]]); + }); + + it('returns 404 so the cart keeps the redirect to PayPal', function (Closure $settings) { + writeShopSettings($this->bertaRoot, $settings()); + + getJson(storefrontUrl('paypal/buttons'))->assertNotFound(); + + Http::assertNothingSent(); + })->with([ + 'Classic credentials' => fn () => nvpSettings(), + 'no PayPal credentials' => fn () => [], + 'bank transfer only' => fn () => [...restSettings(), 'paymentMethod' => 'bank'], + ]); + + it('saves and emails an order paid with the buttons', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + $response = asShopCustomer($token) + ->postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN]) + ->assertOk() + ->assertJsonPath('data.status', 'paid'); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeTrue() + ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue() + ->and($response->getCookie('shopping_cart', false)->isCleared())->toBeTrue(); + Mail::assertSent(ShopOrderMail::class, 2); + }); + + it('saves nothing when PayPal declines a payment from the buttons', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(fn () => Http::response(['name' => 'UNPROCESSABLE_ENTITY', 'details' => [['issue' => 'INSTRUMENT_DECLINED']]], 422)); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'creditcard'])->assertOk(); + + asShopCustomer($token) + ->postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN]) + ->assertOk() + ->assertJsonPath('data.status', 'failed'); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); + Mail::assertNothingSent(); + }); + + it('answers a repeated capture with the first outcome without capturing again', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN])->assertJsonPath('data.status', 'paid'); + + postJson(storefrontUrl('paypal/capture'), ['token' => SHOP_REST_TOKEN])->assertJsonPath('data.status', 'paid'); + + expect(capturesSent())->toBe(1); + }); + + it('takes no second payment for a basket already paid', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + $order = fn (string $id) => ['id' => $id, 'status' => 'CREATED', 'links' => [['rel' => 'approve', 'href' => "https://www.paypal.com/checkoutnow?token={$id}"]]]; + Http::fake([ + 'api-m.paypal.com/v1/oauth2/token' => Http::response(['access_token' => 'A21AA', 'expires_in' => 32400]), + 'api-m.paypal.com/v2/checkout/orders' => Http::sequence()->push($order('CARDORDER1'))->push($order('PAYPALORDER1')), + 'api-m.paypal.com/v2/checkout/orders/*/capture' => Http::response(paidRestOrder()), + ]); + $token = fillShopBasket([[SHOP_CHAIR]]); + // The card form opened, then the customer switched to PayPal's popup + shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true])->assertJsonPath('data.token', 'CARDORDER1'); + shopCheckout($token, ['payment_method' => 'paypal', 'paypal_buttons' => true])->assertJsonPath('data.token', 'PAYPALORDER1'); + postJson(storefrontUrl('paypal/capture'), ['token' => 'PAYPALORDER1'])->assertJsonPath('data.status', 'paid'); + + postJson(storefrontUrl('paypal/capture'), ['token' => 'CARDORDER1'])->assertJsonPath('data.status', 'failed'); + + expect(capturesSent())->toBe(1) + ->and(shopDb()->table('client')->where('session_id', 'checkout')->count())->toBe(1); + Mail::assertSent(ShopOrderMail::class, 2); + }); + + it('captures nothing for a malformed token', function () { + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + + postJson(storefrontUrl('paypal/capture'), ['token' => '../' . SHOP_REST_TOKEN]) + ->assertOk() + ->assertJsonPath('data.status', 'failed'); + + expect(capturesSent())->toBe(0); + }); +}); + +it('lists the products of priced entries for the shop admin', function () { + actingAs(new User); + + getJson('http://shop.test' . route('products', [], false))->assertOk(); + + expect(shopDb()->table('product')->pluck('name')->sort()->values()->all())->toBe(['Chair', 'T-shirt M', 'T-shirt S']); +}); + +it('adds the columns of newer shop versions to an older shop database', function () { + $database = $this->bertaRoot . '/storage/old-shop-db.sqlite'; + $db = new SQLite3($database); + $db->exec('CREATE TABLE client (id INTEGER PRIMARY KEY, created_at datetime NOT NULL, session_id varchar(32) NOT NULL)'); + $db->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, created_at datetime NOT NULL, id_client int NOT NULL, id_product int NOT NULL, paid tinyint DEFAULT 0, price decimal(10,2))'); + $db->close(); + config(['plugin-Shop.database-connections.sqlite.database' => $database]); + writeShopSettings($this->bertaRoot, ['version' => '3']); + + $schema = (new ShopClientsDataService(''))->getConnection()->getSchemaBuilder(); + + expect($schema->hasColumns('client', ['company', 'company_reg_no', 'legal_address']))->toBeTrue() + ->and($schema->hasColumn('orders', 'weight'))->toBeTrue() + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(5); +}); diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index fbc6d9aee..86c9936e9 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -1,5 +1,8 @@ skip()` only applies after the hooks ran. + */ +function skipWithoutShopPlugin(): void +{ + if (! isShopPluginInstalled()) { + Assert::markTestSkipped('Shop plugin not installed'); + } +} + +/** + * A temporary Berta root for the shop plugin's tests, with its shop database and file cache + * + * @return string The root's path, for the test to delete afterwards + */ +function setUpShopTestRoot(): string +{ + $realRoot = config('app.old_berta_root'); + $bertaRoot = sys_get_temp_dir() . '/berta_shop_' . uniqid(); + File::ensureDirectoryExists($bertaRoot . '/storage'); + File::ensureDirectoryExists($bertaRoot . '/engine'); + File::ensureDirectoryExists($bertaRoot . '/_plugin_shop'); + // Site settings read the legacy engine's definitions, and an anonymous request's auth + // check logs out through the legacy engine + foreach (['engine/_classes', 'engine/lang', 'engine/inc.settings.php', 'engine/inc.version.php', '_templates'] as $path) { + symlink("{$realRoot}/{$path}", "{$bertaRoot}/{$path}"); + } + File::copy($realRoot . '/_plugin_shop/inc.setting-definition.php', $bertaRoot . '/_plugin_shop/inc.setting-definition.php'); + File::put($bertaRoot . '/engine/hosting', json_encode(['emailFromAddress' => 'shop@berta.test'])); + + config([ + 'app.old_berta_root' => $bertaRoot, + 'plugin-Shop.key' => 'shop.test', + 'plugin-Shop.database-connections.sqlite.database' => $bertaRoot . '/storage/shop-db.sqlite', + 'plugin-Shop.database-connections.site-sqlite-template.database' => $bertaRoot . '/storage/-sites/{site}/shop-db.sqlite', + 'cache.stores.file.path' => $bertaRoot . '/cache', + ]); + Cache::forgetDriver('file'); + + writeShopSettings($bertaRoot); + + return $bertaRoot; +} + +/** + * @param array $shop + */ +function writeShopSettings(string $bertaRoot, array $shop = [], string $site = ''): void +{ + $shop = array_merge([ + 'paymentMethod' => 'both', + 'email' => 'seller@berta.test', + 'currency' => 'EUR', + 'orderEmailSubject' => 'Your order', + 'promoCode' => 'SAVE10', + 'promoCodeDiscount' => '10', + ], $shop); + + $values = implode('', array_map( + fn ($name, $value) => "<{$name}>", + array_keys($shop), + $shop, + )); + + $siteRoot = $bertaRoot . '/storage' . ($site !== '' ? "/-sites/{$site}" : ''); + File::ensureDirectoryExists($siteRoot); + File::put($siteRoot . '/settings.xml', '' + . "{$values}" + . '' + . ''); +} diff --git a/editor/copy-twig-templates.mjs b/editor/copy-twig-templates.mjs index d6831dd1e..e6bfdee35 100644 --- a/editor/copy-twig-templates.mjs +++ b/editor/copy-twig-templates.mjs @@ -12,7 +12,8 @@ import { rmSync, existsSync } from 'fs'; rmSync('src/templates', { recursive: true, force: true }); } - await cpy('../_api_app/app/**/*.twig', 'src/templates', { + // Plugin templates, such as the shop's order email, aren't rendered in the editor + await cpy(['../_api_app/app/**/*.twig', '!../_api_app/app/Plugins/**'], 'src/templates', { parents: true }); console.log('✓ Twig files copied from backend to Angular app.'); diff --git a/editor/src/app/preview/style.service.spec.ts b/editor/src/app/preview/style.service.spec.ts new file mode 100644 index 000000000..2b2b1327c --- /dev/null +++ b/editor/src/app/preview/style.service.spec.ts @@ -0,0 +1,54 @@ +import { Store } from '@ngxs/store'; + +import { StyleService } from './style.service'; + +describe('StyleService', () => { + const PRICE_COLOR = { + group: 'group_price_item', + slug: 'priceItemFontcolor', + value: '#ff0000', + }; + + let rootState: { shopSettingsConfig?: unknown }; + let styleSheet: CSSStyleSheet; + let service: StyleService; + + beforeEach(() => { + rootState = {}; + const store = { + selectSnapshot: (selector: (state: unknown) => unknown) => + selector(rootState), + } as unknown as Store; + service = new StyleService( + store, + {} as never, + {} as never, + {} as never, + {} as never, + ); + styleSheet = new CSSStyleSheet(); + service.initializeStyleSheet(window, styleSheet); + }); + + it('ignores a shop setting while the shop settings config is not loaded', () => { + expect(() => service.updateShopStyle({ ...PRICE_COLOR }, [])).not.toThrow(); + expect(styleSheet.cssRules.length).toBe(0); + }); + + it('applies a shop setting with the config the shop loaded after the preview', () => { + rootState.shopSettingsConfig = { + group_price_item: { + priceItemFontcolor: { + default: '#333333', + css: [{ selector: '#pageEntries .cartPrice', property: 'color' }], + }, + }, + }; + + service.updateShopStyle({ ...PRICE_COLOR }, []); + + const rule = styleSheet.cssRules[0] as CSSStyleRule; + expect(rule.selectorText).toBe('#pageEntries .cartPrice'); + expect(rule.style.color).toBe('rgb(255, 0, 0)'); + }); +}); diff --git a/editor/src/app/preview/style.service.ts b/editor/src/app/preview/style.service.ts index 00e8c3da6..a9bc3bb36 100644 --- a/editor/src/app/preview/style.service.ts +++ b/editor/src/app/preview/style.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngxs/store'; -import { take } from 'rxjs/operators'; import * as WebFont from 'webfontloader'; import { TemplateSiteModel } from '../sites/template-settings/site-templates.interface'; import { SettingsGroupModel } from '../shared/interfaces'; @@ -18,8 +17,6 @@ import { ShopSettingsConfigModel } from '../shop/settings/shop-settings-config.s export class StyleService { contextWindow: Window; styleSheet: CSSStyleSheet; - templateConfig: TemplateSiteModel['templateConf']; - shopSettingsConfig: ShopSettingsConfigModel; constructor( private store: Store, @@ -32,19 +29,6 @@ export class StyleService { initializeStyleSheet(contextWindow: Window, styleSheet: CSSStyleSheet) { this.contextWindow = contextWindow; this.styleSheet = styleSheet; - this.store - .select(SiteTemplatesState.getCurrentTemplateConfig) - .pipe(take(1)) - .subscribe((templateConfig) => { - this.templateConfig = templateConfig; - }); - - this.store - .select((state) => state.shopSettingsConfig) - .pipe(take(1)) - .subscribe((shopSettingsConfig) => { - this.shopSettingsConfig = shopSettingsConfig; - }); } updateStyle( @@ -53,7 +37,10 @@ export class StyleService { style, templateSettings: SettingsGroupModel[], ) { - const settingGroup = this.templateConfig[style.group]; + // Read when a setting changes: the template's config may load after the preview + const templateConfig: TemplateSiteModel['templateConf'] | undefined = + this.store.selectSnapshot(SiteTemplatesState.getCurrentTemplateConfig); + const settingGroup = templateConfig?.[style.group]; if (!settingGroup) { return; } @@ -130,7 +117,10 @@ export class StyleService { } updateShopStyle(style, templateSettings: SettingsGroupModel[]) { - const settingGroup = this.shopSettingsConfig[style.group]; + // Read when a setting changes: the shop's config loads with the shop, after the preview + const shopSettingsConfig: ShopSettingsConfigModel | undefined = + this.store.selectSnapshot((state) => state.shopSettingsConfig); + const settingGroup = shopSettingsConfig?.[style.group]; if (!settingGroup) { return; } diff --git a/editor/src/app/render/messy-template-render.service.ts b/editor/src/app/render/messy-template-render.service.ts index 965c0c0b0..5e4a9f390 100644 --- a/editor/src/app/render/messy-template-render.service.ts +++ b/editor/src/app/render/messy-template-render.service.ts @@ -282,7 +282,6 @@ export class MessyTemplateRenderService extends TemplateRenderService { commonViewData.templateName, commonViewData.user, ), - alertMessage: false, cartSection: commonViewData.isShopAvailable && commonViewData.currentSectionType === 'shopping_cart' diff --git a/editor/src/app/render/template-render.service.ts b/editor/src/app/render/template-render.service.ts index 964788ef7..e455a08c7 100644 --- a/editor/src/app/render/template-render.service.ts +++ b/editor/src/app/render/template-render.service.ts @@ -202,6 +202,9 @@ export class TemplateRenderService { const siteTemplateSectionTypes = this.store.selectSnapshot( SiteTemplatesState.getCurrentTemplateSectionTypes, ); + const siteTemplateConfig = this.store.selectSnapshot( + SiteTemplatesState.getCurrentTemplateConfig, + ); const entries = this.store.selectSnapshot( SectionEntriesState.getCurrentSiteEntries, @@ -220,8 +223,11 @@ export class TemplateRenderService { siteTemplateSettings.pageLayout.autoResponsive === 'yes'; let shopSettings = {}; + let shopSettingsConfig = {}; if (isShopAvailable) { + shopSettingsConfig = + this.store.selectSnapshot((state) => state.shopSettingsConfig) || {}; const shopSettingsState = this.store.selectSnapshot( ShopSettingsState.getCurrentSiteSettings, ); @@ -282,6 +288,8 @@ export class TemplateRenderService { isResponsive, isAutoResponsive, user, + siteTemplateConfig, + shopSettingsConfig, ), googleTagManagerNoscript: this.googleTagManagerNoscriptRender(siteSettings), diff --git a/editor/src/app/render/twig-templates.ts b/editor/src/app/render/twig-templates.ts index 128e84ba5..71ea567b5 100644 --- a/editor/src/app/render/twig-templates.ts +++ b/editor/src/app/render/twig-templates.ts @@ -16,6 +16,12 @@ export const TWIG_TEMPLATES: Record = {
+ +
@@ -118,7 +124,6 @@ export const TWIG_TEMPLATES: Record = {
- {{ shippingTotal.content }} @@ -130,8 +135,6 @@ export const TWIG_TEMPLATES: Record = {
- - {% if isEditMode %} {% if discount.content %}{{ discount.content }}{% else %} discount {% endif %} @@ -160,8 +163,8 @@ export const TWIG_TEMPLATES: Record = { {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %}
{% endif %} -
{{ currency }}
+
@@ -188,7 +191,7 @@ export const TWIG_TEMPLATES: Record = {

-

-

-

-

@@ -278,7 +281,7 @@ export const TWIG_TEMPLATES: Record = {

-

@@ -326,7 +329,7 @@ export const TWIG_TEMPLATES: Record = {

-

@@ -371,7 +374,7 @@ export const TWIG_TEMPLATES: Record = {

-

@@ -430,22 +433,20 @@ export const TWIG_TEMPLATES: Record = {

{% endif %} - - {% if isPaypalAvailable %}
-
-
{% endif %} {% if isManualTransferAvailable %}
-
- {{ addToBasketLabel }} - + +
`, @@ -1215,9 +1216,6 @@ export const TWIG_TEMPLATES: Record = { {{ sectionFooter|raw }}
{% endif %} - {% if alertMessage %} - - {% endif %} `, diff --git a/editor/src/app/shop/settings/shop-settings.component.ts b/editor/src/app/shop/settings/shop-settings.component.ts index 67b9f4f50..7b4abeea4 100644 --- a/editor/src/app/shop/settings/shop-settings.component.ts +++ b/editor/src/app/shop/settings/shop-settings.component.ts @@ -17,6 +17,12 @@ import { UpdateShopSettingsAction } from './shop-settings.actions';

{{ settingGroup.config.title || settingGroup.slug }}

+ @if (settingGroup.config.description) { +

+ }
@for (setting of settingGroup.settings; track setting) { { + // The cart's editable texts in the editor while the site's texts are empty, + // the same as the site's own renderer gives in editor mode + const DEFAULT_TEXTS = { + emptyCart: { + attributes: + ' class="xNgEditable xProperty-cartTextEmpty xCaption-shopping+cart+empty" data-path="blog/settings/siteTexts/cartTextEmpty" data-empty-caption="shopping cart empty"', + content: 'shopping cart is empty', + }, + expiredCart: { + attributes: + ' class="xNgEditable xProperty-cartTextExpired xCaption-shopping+cart+is+expired" data-path="blog/settings/siteTexts/cartTextExpired" data-empty-caption="shopping cart is expired"', + content: 'shopping cart is expired', + }, + tableHeadTitle: { + attributes: + ' class="xNgEditable xProperty-cartTextTitle xCaption-title" style="width: 100px;" data-path="blog/settings/siteTexts/cartTextTitle" data-empty-caption="title"', + content: '', + }, + tableHeadQuantity: { + attributes: + ' class="xNgEditable xProperty-cartTextQuantity xCaption-quantity" data-path="blog/settings/siteTexts/cartTextQuantity" data-empty-caption="quantity"', + content: '', + }, + tableHeadPrice: { + attributes: + ' class="xNgEditable xProperty-cartTextPrice xCaption-price" data-path="blog/settings/siteTexts/cartTextPrice" data-empty-caption="price"', + content: '', + }, + tableHeadSum: { + attributes: + ' class="xNgEditable xProperty-cartTextSum xCaption-sum" data-path="blog/settings/siteTexts/cartTextSum" data-empty-caption="sum"', + content: '', + }, + promoCode: { + attributes: + ' class="xNgEditable xProperty-cartTextPromoCode xCaption-Promo+code" data-path="blog/settings/siteTexts/cartTextPromoCode" data-empty-caption="Promo code"', + content: '', + }, + promoButton: { + attributes: + ' class="xNgEditable xProperty-cartTextPromoBtn xCaption-OK" data-path="blog/settings/siteTexts/cartTextPromoBtn" data-empty-caption="OK"', + content: '', + }, + shippingTo: { + attributes: + ' class="cCartShippingTo xNgEditable xProperty-cartTextShippingTo xCaption-shipping+to" data-path="blog/settings/siteTexts/cartTextShippingTo" data-empty-caption="shipping to"', + content: 'shipping to', + }, + shippingTotal: { + attributes: + ' class="xNgEditable xProperty-shippingTotalText xCaption-Shipping" data-path="blog/settings/siteTexts/shippingTotalText" data-empty-caption="Shipping"', + content: 'Shipping', + }, + discount: { + attributes: + ' class="xNgEditable xProperty-cartTextDiscount xCaption-discount" data-path="blog/settings/siteTexts/cartTextDiscount" data-empty-caption="discount"', + content: '', + }, + total: { + attributes: + ' class="xNgEditable xProperty-cartTextTotal xCaption-total" data-path="blog/settings/siteTexts/cartTextTotal" data-empty-caption="total"', + content: '', + }, + includedVat: { + attributes: + ' class="xNgEditable xProperty-cartTextInclVat xCaption-incl+vat" data-path="blog/settings/siteTexts/cartTextInclVat" data-empty-caption="incl vat"', + content: '', + }, + billingAddress: { + attributes: + ' class="xNgEditable xProperty-cartTextBillAddr xCaption-billing+address" data-path="blog/settings/siteTexts/cartTextBillAddr" data-empty-caption="billing address"', + content: 'Billing address', + }, + legalPerson: { + attributes: + ' class="xNgEditable xProperty-cartTextLegalPerson xCaption-legal+person" data-path="blog/settings/siteTexts/cartTextLegalPerson" data-empty-caption="legal person"', + content: '', + }, + company: { + attributes: + ' class="xNgEditable xProperty-cartTextCompany xCaption-Company" data-path="blog/settings/siteTexts/cartTextCompany" data-empty-caption="Company"', + content: '', + }, + companyRegistrationNumber: { + attributes: + ' class="xNgEditable xProperty-cartTextCompanyRegNo xCaption-Company+reg.+no." data-path="blog/settings/siteTexts/cartTextCompanyRegNo" data-empty-caption="Company reg. no."', + content: '', + }, + legalAddress: { + attributes: + ' class="xNgEditable xProperty-cartTextLegalAddress xCaption-Legal+address" data-path="blog/settings/siteTexts/cartTextLegalAddress" data-empty-caption="Legal address"', + content: '', + }, + nameSurname: { + attributes: + ' class="xNgEditable xProperty-cartTextNameSurname xCaption-name+surname" data-path="blog/settings/siteTexts/cartTextNameSurname" data-empty-caption="name surname"', + content: '', + }, + address: { + attributes: + ' class="xNgEditable xProperty-cartTextAddress xCaption-address" data-path="blog/settings/siteTexts/cartTextAddress" data-empty-caption="address"', + content: '', + }, + phone: { + attributes: + ' class="xNgEditable xProperty-cartTextPhoneNumber xCaption-phone+number" data-path="blog/settings/siteTexts/cartTextPhoneNumber" data-empty-caption="phone number"', + content: '', + }, + email: { + attributes: + ' class="xNgEditable xProperty-cartTextEmail xCaption-email" data-path="blog/settings/siteTexts/cartTextEmail" data-empty-caption="email"', + content: '', + }, + comments: { + attributes: + ' class="xNgEditable xProperty-cartTextComments xCaption-comments" data-path="blog/settings/siteTexts/cartTextComments" data-empty-caption="comments"', + content: '', + }, + deliverToBillingAddress: { + attributes: + ' class="xNgEditable xProperty-cartTextDeliverBilling xCaption-deliver+to+billing_address" data-path="blog/settings/siteTexts/cartTextDeliverBilling" data-empty-caption="deliver to billing_address"', + content: '', + }, + shippingAddressHeader: { + attributes: + ' class="xNgEditable xProperty-cartTextShipAddr xCaption-shipping+address" data-path="blog/settings/siteTexts/cartTextShipAddr" data-empty-caption="shipping address"', + content: '', + }, + receiveNews: { + attributes: + ' class="xNgEditable xProperty-cartTextReceiveNews xCaption-receive+news+and+updates" data-path="blog/settings/siteTexts/cartTextReceiveNews" data-empty-caption="receive news and updates"', + content: '', + }, + paymentHeader: { + attributes: + ' id="cartFromRightTitle" class="xNgEditable xProperty-cartTextCPM xCaption-choose+payment+method" data-path="blog/settings/siteTexts/cartTextCPM" data-empty-caption="choose payment method"', + content: 'Choose payment method', + }, + paymentDescription: { + attributes: + ' class="xNgEditable xProperty-cartTextPaymentDescription xCaption-payment+description" data-path="blog/settings/siteTexts/cartTextPaymentDescription" data-empty-caption="payment description"', + content: '', + }, + manualTransfer: { + attributes: + ' class="xNgEditable xProperty-cartManualTransfer xCaption-Manual+transfer" data-path="blog/settings/siteTexts/cartManualTransfer" data-empty-caption="Manual transfer"', + content: '', + }, + checkoutDescription: { + attributes: + ' class="xNgEditable xProperty-cartTextCheckoutDescription xCaption-checkout+description" data-path="blog/settings/siteTexts/cartTextCheckoutDescription" data-empty-caption="checkout description"', + content: '', + }, + terms: { + attributes: + ' class="xNgEditable xProperty-cartTextTerms xCaption-By+purchasing+our+products+you+agree+to+the+Terms+of+Service" data-path="blog/settings/siteTexts/cartTextTerms" data-empty-caption="By purchasing our products you agree to the Terms of Service"', + content: '', + }, + fillRequiredFields: { + attributes: + ' class="xNgEditable xProperty-cartTextFillRequiredFields xCaption-Please+fill+in+all+required+fields" data-path="blog/settings/siteTexts/cartTextFillRequiredFields" data-empty-caption="Please fill in all required fields"', + content: '', + }, + checkoutButton: { + attributes: + ' class="xNgEditable xProperty-cartTextCheckoutButton xCaption-checkout+and+pay" data-path="blog/settings/siteTexts/cartTextCheckoutButton" data-empty-caption="checkout and pay"', + content: '', + }, + returnToStore: { + attributes: + ' class="xNgEditable xProperty-cartTextReturnStore xCaption-return+to+store" data-path="blog/settings/siteTexts/cartTextReturnStore" data-empty-caption="return to store"', + content: '', + }, + }; + + function renderCartViewData(siteTexts: Record) { + const render = jasmine.createSpy('render').and.returnValue(''); + const service = new ShopCartRenderService({ + render, + } as unknown as TwigTemplateRenderService); + + service.renderCart( + 'blog', + { siteTexts }, + 'messy', + { name: 'cart', title: 'Cart' } as SiteSectionStateModel, + { + group_config: { + currency: 'EUR', + promoCodeDiscount: '10', + paymentMethod: 'both', + }, + }, + [], + ); + + expect(render).toHaveBeenCalledWith( + 'Shop/shoppingCart', + jasmine.anything(), + ); + return render.calls.mostRecent().args[1]; + } + + function settingOf(attributes: string): string { + return attributes.match(/siteTexts\/(\w+)"/)[1]; + } + + it('renders every editable text with its default', () => { + const viewData = renderCartViewData({}); + + for (const [name, text] of Object.entries(DEFAULT_TEXTS)) { + expect(viewData[name]).withContext(name).toEqual(text); + } + expect(viewData.totalLabel).toBe('total'); + }); + + it("renders the site's own texts", () => { + const siteTexts = Object.fromEntries( + Object.values(DEFAULT_TEXTS).map(({ attributes }) => [ + settingOf(attributes), + `Custom ${settingOf(attributes)}`, + ]), + ); + siteTexts.cartTextTotal = 'Custom cartTextTotal'; + + const viewData = renderCartViewData(siteTexts); + + for (const [name, { attributes }] of Object.entries(DEFAULT_TEXTS)) { + expect(viewData[name]) + .withContext(name) + .toEqual({ attributes, content: `Custom ${settingOf(attributes)}` }); + } + expect(viewData.totalLabel).toBe('Custom cartTextTotal'); + }); +}); diff --git a/editor/src/app/shop/shop-cart-render.service.ts b/editor/src/app/shop/shop-cart-render.service.ts index 443c28e2a..a54c42b90 100644 --- a/editor/src/app/shop/shop-cart-render.service.ts +++ b/editor/src/app/shop/shop-cart-render.service.ts @@ -3,6 +3,222 @@ import { getImageItem, toHtmlAttributes } from '../shared/helpers'; import { SiteSectionStateModel } from '../sites/sections/sections-state/site-sections-state.model'; import { TwigTemplateRenderService } from '../render/twig-template-render.service'; +interface EditableText { + setting: string; + default: string; + caption: string; + siteOnly?: boolean; + id?: string; + class?: string; + editorStyle?: string; +} + +/** + * The cart's texts the site owner edits in place, by view variable. A text shows the site + * text `setting`, or `default` while that's empty; a `siteOnly` default isn't shown in the + * editor, where an empty text shows its `caption` instead. + */ +const EDITABLE_TEXTS: Record = { + emptyCart: { + setting: 'cartTextEmpty', + default: 'shopping cart is empty', + caption: 'shopping cart empty', + }, + expiredCart: { + setting: 'cartTextExpired', + default: 'shopping cart is expired', + caption: 'shopping cart is expired', + }, + tableHeadTitle: { + setting: 'cartTextTitle', + default: 'title', + caption: 'title', + siteOnly: true, + editorStyle: 'width: 100px;', + }, + tableHeadQuantity: { + setting: 'cartTextQuantity', + default: 'quantity', + caption: 'quantity', + siteOnly: true, + }, + tableHeadPrice: { + setting: 'cartTextPrice', + default: 'price', + caption: 'price', + siteOnly: true, + }, + tableHeadSum: { + setting: 'cartTextSum', + default: 'sum', + caption: 'sum', + siteOnly: true, + }, + promoCode: { + setting: 'cartTextPromoCode', + default: 'promo code', + caption: 'Promo code', + siteOnly: true, + }, + promoButton: { + setting: 'cartTextPromoBtn', + default: 'ok', + caption: 'OK', + siteOnly: true, + }, + shippingTo: { + setting: 'cartTextShippingTo', + default: 'shipping to', + caption: 'shipping to', + class: 'cCartShippingTo', + }, + shippingTotal: { + setting: 'shippingTotalText', + default: 'Shipping', + caption: 'Shipping', + }, + discount: { + setting: 'cartTextDiscount', + default: 'discount', + caption: 'discount', + siteOnly: true, + }, + total: { + setting: 'cartTextTotal', + default: 'total', + caption: 'total', + siteOnly: true, + }, + includedVat: { + setting: 'cartTextInclVat', + default: 'incl. VAT', + caption: 'incl vat', + siteOnly: true, + }, + billingAddress: { + setting: 'cartTextBillAddr', + default: 'Billing address', + caption: 'billing address', + }, + legalPerson: { + setting: 'cartTextLegalPerson', + default: 'Legal person', + caption: 'legal person', + siteOnly: true, + }, + company: { + setting: 'cartTextCompany', + default: 'Company', + caption: 'Company', + siteOnly: true, + }, + companyRegistrationNumber: { + setting: 'cartTextCompanyRegNo', + default: 'Company reg. no.', + caption: 'Company reg. no.', + siteOnly: true, + }, + legalAddress: { + setting: 'cartTextLegalAddress', + default: 'Legal address', + caption: 'Legal address', + siteOnly: true, + }, + nameSurname: { + setting: 'cartTextNameSurname', + default: 'Name, surname', + caption: 'name surname', + siteOnly: true, + }, + address: { + setting: 'cartTextAddress', + default: 'Address', + caption: 'address', + siteOnly: true, + }, + phone: { + setting: 'cartTextPhoneNumber', + default: 'Phone', + caption: 'phone number', + siteOnly: true, + }, + email: { + setting: 'cartTextEmail', + default: 'Email', + caption: 'email', + siteOnly: true, + }, + comments: { + setting: 'cartTextComments', + default: 'Comments', + caption: 'comments', + siteOnly: true, + }, + deliverToBillingAddress: { + setting: 'cartTextDeliverBilling', + default: 'Deliver to billing address', + caption: 'deliver to billing_address', + siteOnly: true, + }, + shippingAddressHeader: { + setting: 'cartTextShipAddr', + default: 'Shippping address', + caption: 'shipping address', + siteOnly: true, + }, + receiveNews: { + setting: 'cartTextReceiveNews', + default: '', + caption: 'receive news and updates', + }, + paymentHeader: { + setting: 'cartTextCPM', + default: 'Choose payment method', + caption: 'choose payment method', + id: 'cartFromRightTitle', + }, + paymentDescription: { + setting: 'cartTextPaymentDescription', + default: '', + caption: 'payment description', + }, + manualTransfer: { + setting: 'cartManualTransfer', + default: 'Manual transfer', + caption: 'Manual transfer', + siteOnly: true, + }, + checkoutDescription: { + setting: 'cartTextCheckoutDescription', + default: '', + caption: 'checkout description', + }, + terms: { + setting: 'cartTextTerms', + default: 'By purchasing our products you agree to the Terms of Service', + caption: 'By purchasing our products you agree to the Terms of Service', + siteOnly: true, + }, + fillRequiredFields: { + setting: 'cartTextFillRequiredFields', + default: 'Please fill in all required fields', + caption: 'Please fill in all required fields', + siteOnly: true, + }, + checkoutButton: { + setting: 'cartTextCheckoutButton', + default: 'checkout and pay', + caption: 'checkout and pay', + siteOnly: true, + }, + returnToStore: { + setting: 'cartTextReturnStore', + default: 'return to store', + caption: 'return to store', + siteOnly: true, + }, +}; + @Injectable({ providedIn: 'root', }) @@ -94,568 +310,23 @@ export class ShopCartRenderService { } } - getEmptyCartData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextEmpty', - 'xCaption-shopping+cart+empty', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextEmpty`, - 'data-empty-caption': 'shopping cart empty', - }), - content: siteSettings.siteTexts.cartTextEmpty - ? siteSettings.siteTexts.cartTextEmpty - : 'shopping cart is empty', - }; - } - - getExpiredCartData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextExpired', - 'xCaption-shopping+cart+is+expired', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextExpired`, - 'data-empty-caption': 'shopping cart is expired', - }), - content: siteSettings.siteTexts.cartTextExpired - ? siteSettings.siteTexts.cartTextExpired - : 'shopping cart is expired', - }; - } - - getTableHeadTitleData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: ['xNgEditable', 'xProperty-cartTextTitle', 'xCaption-title'].join( - ' ', - ), - style: 'width: 100px', - 'data-path': `${siteSlug}/settings/siteTexts/cartTextTitle`, - 'data-empty-caption': 'title', - }), - content: siteSettings.siteTexts.cartTextTitle - ? siteSettings.siteTexts.cartTextTitle - : '', - }; - } - - getTableHeadQuantityData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextQuantity', - 'xCaption-quantity', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextQuantity`, - 'data-empty-caption': 'quantity', - }), - content: siteSettings.siteTexts.cartTextQuantity - ? siteSettings.siteTexts.cartTextQuantity - : '', - }; - } - - getTableHeadPriceData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: ['xNgEditable', 'xProperty-cartTextPrice', 'xCaption-price'].join( - ' ', - ), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextPrice`, - 'data-empty-caption': 'price', - }), - content: siteSettings.siteTexts.cartTextPrice - ? siteSettings.siteTexts.cartTextPrice - : '', - }; - } - - getTableHeadSumData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: ['xNgEditable', 'xProperty-cartTextSum', 'xCaption-sum'].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextSum`, - 'data-empty-caption': 'sum', - }), - content: siteSettings.siteTexts.cartTextSum - ? siteSettings.siteTexts.cartTextSum - : '', - }; - } - - getPromoCodeData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextPromoCode', - 'xCaption-Promo+code', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextPromoCode`, - 'data-empty-caption': 'Promo code', - }), - content: siteSettings.siteTexts.cartTextPromoCode - ? siteSettings.siteTexts.cartTextPromoCode - : '', - }; - } - - getPromoButtonData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: ['xNgEditable', 'xProperty-cartTextPromoBtn', 'xCaption-OK'].join( - ' ', - ), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextPromoBtn`, - 'data-empty-caption': 'OK', - }), - content: siteSettings.siteTexts.cartTextPromoBtn - ? siteSettings.siteTexts.cartTextPromoBtn - : '', - }; - } - - getShippingToData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextShippingTo', - 'xCaption-shipping+to', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextShippingTo`, - 'data-empty-caption': 'shipping to', - }), - content: siteSettings.siteTexts.cartTextShippingTo - ? siteSettings.siteTexts.cartTextShippingTo - : 'shipping to', - }; - } - - getShippingTotalData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-shippingTotalText', - 'xCaption-Shipping', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/shippingTotalText`, - 'data-empty-caption': 'Shipping', - }), - content: siteSettings.siteTexts.shippingTotalText - ? siteSettings.siteTexts.shippingTotalText - : 'Shipping', - }; - } - - getDiscountData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextDiscount', - 'xCaption-discount', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextDiscount`, - 'data-empty-caption': 'discount', - }), - content: siteSettings.siteTexts.cartTextDiscount - ? siteSettings.siteTexts.cartTextDiscount - : '', - }; - } - - getTotalData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: ['xNgEditable', 'xProperty-cartTextTotal', 'xCaption-total'].join( - ' ', - ), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextTotal`, - 'data-empty-caption': 'total', - }), - content: siteSettings.siteTexts.cartTextTotal - ? siteSettings.siteTexts.cartTextTotal - : '', - }; - } - - getIncludedVatData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextInclVat', - 'xCaption-incl+vat', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextInclVat`, - 'data-empty-caption': 'incl vat', - }), - content: siteSettings.siteTexts.cartTextInclVat - ? siteSettings.siteTexts.cartTextInclVat - : '', - }; - } - - getBillingAddressData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextBillAddr', - 'xCaption-billing+address', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextBillAddr`, - 'data-empty-caption': 'billing address', - }), - content: siteSettings.siteTexts.cartTextBillAddr - ? siteSettings.siteTexts.cartTextBillAddr - : 'Billing address', - }; - } - - getLegalPersonData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextLegalPerson', - 'xCaption-legal+person', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextLegalPerson`, - 'data-empty-caption': 'legal person', - }), - content: siteSettings.siteTexts.cartTextLegalPerson - ? siteSettings.siteTexts.cartTextLegalPerson - : '', - }; - } - - getCompanyData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextCompany', - 'xCaption-Company', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextCompany`, - 'data-empty-caption': 'Company', - }), - content: siteSettings.siteTexts.cartTextCompany - ? siteSettings.siteTexts.cartTextCompany - : '', - }; - } - - getCompanyRegistrationNumberData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextCompanyRegNo', - 'xCaption-Company+reg.+no.', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextCompanyRegNo`, - 'data-empty-caption': 'Company reg. no.', - }), - content: siteSettings.siteTexts.cartTextCompanyRegNo - ? siteSettings.siteTexts.cartTextCompanyRegNo - : '', - }; - } - - getLegalAddressData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextLegalAddress', - 'xCaption-Legal+address', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextLegalAddress`, - 'data-empty-caption': 'Legal address', - }), - content: siteSettings.siteTexts.cartTextLegalAddress - ? siteSettings.siteTexts.cartTextLegalAddress - : '', - }; - } - - getNameSurnameData(siteSlug, siteSettings) { + getEditableTextData(siteSlug: string, siteSettings, text: EditableText) { return { attributes: toHtmlAttributes({ + id: text.id, class: [ + ...(text.class ? [text.class] : []), 'xNgEditable', - 'xProperty-cartTextNameSurname', - 'xCaption-name+surname', + `xProperty-${text.setting}`, + `xCaption-${text.caption.replace(/ /g, '+')}`, ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextNameSurname`, - 'data-empty-caption': 'name surname', + style: text.editorStyle, + 'data-path': `${siteSlug}/settings/siteTexts/${text.setting}`, + 'data-empty-caption': text.caption, }), - content: siteSettings.siteTexts.cartTextNameSurname - ? siteSettings.siteTexts.cartTextNameSurname - : '', - }; - } - - getAddressData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextAddress', - 'xCaption-address', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextAddress`, - 'data-empty-caption': 'address', - }), - content: siteSettings.siteTexts.cartTextAddress - ? siteSettings.siteTexts.cartTextAddress - : '', - }; - } - - getPhoneData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextPhoneNumber', - 'xCaption-phone+number', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextPhoneNumber`, - 'data-empty-caption': 'phone number', - }), - content: siteSettings.siteTexts.cartTextPhoneNumber - ? siteSettings.siteTexts.cartTextPhoneNumber - : '', - }; - } - - getEmailData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: ['xNgEditable', 'xProperty-cartTextEmail', 'xCaption-email'].join( - ' ', - ), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextEmail`, - 'data-empty-caption': 'email', - }), - content: siteSettings.siteTexts.cartTextEmail - ? siteSettings.siteTexts.cartTextEmail - : '', - }; - } - - getCommentsData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextComments', - 'xCaption-comments', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextComments`, - 'data-empty-caption': 'comments', - }), - content: siteSettings.siteTexts.cartTextComments - ? siteSettings.siteTexts.cartTextComments - : '', - }; - } - - getDeliverToBillingAddressData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextDeliverBilling', - 'xCaption-deliver+to+billing_address', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextDeliverBilling`, - 'data-empty-caption': 'deliver to billing_address', - }), - content: siteSettings.siteTexts.cartTextDeliverBilling - ? siteSettings.siteTexts.cartTextDeliverBilling - : '', - }; - } - - getShippingAddressHeaderData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextShipAddr', - 'xCaption-shipping+address', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextShipAddr`, - 'data-empty-caption': 'shipping address', - }), - content: siteSettings.siteTexts.cartTextShipAddr - ? siteSettings.siteTexts.cartTextShipAddr - : '', - }; - } - - getReceiveNewsData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextReceiveNews', - 'xCaption-receive+news+and+updates', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextReceiveNews`, - 'data-empty-caption': 'receive news and updates', - }), - content: siteSettings.siteTexts.cartTextReceiveNews - ? siteSettings.siteTexts.cartTextReceiveNews - : '', - }; - } - - getPaymentHeaderData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextCPM', - 'xCaption-choose+payment+method', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextCPM`, - 'data-empty-caption': 'choose payment method', - }), - content: siteSettings.siteTexts.cartTextCPM - ? siteSettings.siteTexts.cartTextCPM - : 'Choose payment method', - }; - } - - getPaymentDescriptionData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextPaymentDescription', - 'xCaption-payment+description', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextPaymentDescription`, - 'data-empty-caption': 'payment description', - }), - content: siteSettings.siteTexts.cartTextPaymentDescription - ? siteSettings.siteTexts.cartTextPaymentDescription - : '', - }; - } - - getManualTransferData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartManualTransfer', - 'xCaption-Manual+transfer', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartManualTransfer`, - 'data-empty-caption': 'Manual transfer', - }), - content: siteSettings.siteTexts.cartManualTransfer - ? siteSettings.siteTexts.cartManualTransfer - : '', - }; - } - - getCheckoutDescriptionData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextCheckoutDescription', - 'xCaption-checkout+description', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextCheckoutDescription`, - 'data-empty-caption': 'checkout description', - }), - content: siteSettings.siteTexts.cartTextCheckoutDescription - ? siteSettings.siteTexts.cartTextCheckoutDescription - : '', - }; - } - - getTermsData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextTerms', - 'xCaption-By+purchasing+our+products+you+agree+to+the+Terms+of+Service', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextTerms`, - 'data-empty-caption': 'By purchasing our products you agree to the Terms of Service', - }), - content: siteSettings.siteTexts.cartTextTerms - ? siteSettings.siteTexts.cartTextTerms - : '', - }; - } - - getFillRequiredFieldsData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextFillRequiredFields', - 'xCaption-Please+fill+in+all+required+fields', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextFillRequiredFields`, - 'data-empty-caption': 'Please fill in all required fields', - }), - content: siteSettings.siteTexts.cartTextFillRequiredFields - ? siteSettings.siteTexts.cartTextFillRequiredFields - : '', - }; - } - - getCheckoutButtonData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextCheckoutButton', - 'xCaption-checkout+and+pay', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextCheckoutButton`, - 'data-empty-caption': 'checkout and pay', - }), - content: siteSettings.siteTexts.cartTextCheckoutButton - ? siteSettings.siteTexts.cartTextCheckoutButton - : '', - }; - } - - getReturnToStoreData(siteSlug, siteSettings) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextReturnStore', - 'xCaption-return+to+store', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextReturnStore`, - 'data-empty-caption': 'return to store', - }), - content: siteSettings.siteTexts.cartTextReturnStore - ? siteSettings.siteTexts.cartTextReturnStore - : '', + content: + siteSettings.siteTexts[text.setting] || + (text.siteOnly ? '' : text.default), }; } @@ -671,74 +342,29 @@ export class ShopCartRenderService { return ''; } + const editableTexts = Object.fromEntries( + Object.entries(EDITABLE_TEXTS).map(([name, text]) => [ + name, + this.getEditableTextData(siteSlug, siteSettings, text), + ]), + ); const viewData = { + ...editableTexts, config: shopSettings.group_config, currency: shopSettings.group_config.currency, isEditMode: true, - emptyCart: this.getEmptyCartData(siteSlug, siteSettings), cartTitle: section.title, - expiredCart: this.getExpiredCartData(siteSlug, siteSettings), - tableHeadTitle: this.getTableHeadTitleData(siteSlug, siteSettings), - tableHeadQuantity: this.getTableHeadQuantityData(siteSlug, siteSettings), - tableHeadPrice: this.getTableHeadPriceData(siteSlug, siteSettings), - tableHeadSum: this.getTableHeadSumData(siteSlug, siteSettings), discountAvailable: shopSettings.group_config.promoCodeDiscount.length > 0, - promoCode: this.getPromoCodeData(siteSlug, siteSettings), - promoButton: this.getPromoButtonData(siteSlug, siteSettings), - shippingTo: this.getShippingToData(siteSlug, siteSettings), shippingRegions: shippingRegions, - shippingTotal: this.getShippingTotalData(siteSlug, siteSettings), - discount: this.getDiscountData(siteSlug, siteSettings), - total: this.getTotalData(siteSlug, siteSettings), - includedVat: this.getIncludedVatData(siteSlug, siteSettings), - billingAddress: this.getBillingAddressData(siteSlug, siteSettings), - legalPerson: this.getLegalPersonData(siteSlug, siteSettings), - company: this.getCompanyData(siteSlug, siteSettings), - companyRegistrationNumber: this.getCompanyRegistrationNumberData( - siteSlug, - siteSettings, - ), - legalAddress: this.getLegalAddressData(siteSlug, siteSettings), - nameSurname: this.getNameSurnameData(siteSlug, siteSettings), - address: this.getAddressData(siteSlug, siteSettings), - phone: this.getPhoneData(siteSlug, siteSettings), - email: this.getEmailData(siteSlug, siteSettings), - comments: this.getCommentsData(siteSlug, siteSettings), - deliverToBillingAddress: this.getDeliverToBillingAddressData( - siteSlug, - siteSettings, - ), - shippingAddressHeader: this.getShippingAddressHeaderData( - siteSlug, - siteSettings, - ), - receiveNews: this.getReceiveNewsData(siteSlug, siteSettings), - paymentHeader: this.getPaymentHeaderData(siteSlug, siteSettings), - paymentDescription: this.getPaymentDescriptionData( - siteSlug, - siteSettings, - ), isPaypalAvailable: ['paypal', 'both'].includes( shopSettings.group_config.paymentMethod, ), isManualTransferAvailable: ['bank', 'both'].includes( shopSettings.group_config.paymentMethod, ), - manualTransfer: this.getManualTransferData(siteSlug, siteSettings), - checkoutDescription: this.getCheckoutDescriptionData( - siteSlug, - siteSettings, - ), totalLabel: siteSettings.siteTexts.cartTextTotal ? siteSettings.siteTexts.cartTextTotal : 'total', - terms: this.getTermsData(siteSlug, siteSettings), - fillRequiredFields: this.getFillRequiredFieldsData( - siteSlug, - siteSettings, - ), - checkoutButton: this.getCheckoutButtonData(siteSlug, siteSettings), - returnToStore: this.getReturnToStoreData(siteSlug, siteSettings), }; try { diff --git a/editor/src/app/sites/sections/section-head-render.service.spec.ts b/editor/src/app/sites/sections/section-head-render.service.spec.ts new file mode 100644 index 000000000..1f56af395 --- /dev/null +++ b/editor/src/app/sites/sections/section-head-render.service.spec.ts @@ -0,0 +1,72 @@ +import { SectionHeadRenderService } from './section-head-render.service'; +import { TwigTemplateRenderService } from '../../render/twig-template-render.service'; + +describe('SectionHeadRenderService', () => { + const PRICE_ITEM = { + priceItemFontcolor: '#ff0000', + priceItemfontFamily: 'Arial, sans-serif', + priceItemgoogleFont: 'Roboto:400,700', + priceItemfontSize: '', + priceItemfontWeight: 'bold', + priceItemfontStyle: 'normal', + priceItemfontVariant: 'normal', + priceItemlineHeight: '14px', + }; + const ENTRY_HEADING = { + fontFamily: 'Arial, sans-serif', + googleFont: '', + fontWeight: 'bold', + fontStyle: '', + fontVariant: 'normal', + }; + + function shopStyles(pageLayout: Record) { + const service = new SectionHeadRenderService( + {} as TwigTemplateRenderService, + ); + + return service.getStyles( + { version: '2.11.0' }, + '', + { template: { template: 'messy-0.4.2' } }, + null, + { pageLayout, entryHeading: ENTRY_HEADING, css: { customCSS: '' } }, + { default: { entryPadding: '0' } }, + 'messy', + 'shop', + true, + { group_price_item: PRICE_ITEM }, + false, + false, + { + entryHeading: { + fontStyle: { default: 'normal' }, + fontWeight: { default: 'normal' }, + }, + }, + { group_price_item: { priceItemfontSize: { default: '12px' } } }, + ); + } + + it('links the static shop styles and writes the price and cart heading fonts', () => { + const styles = shopStyles({}); + + expect(styles.cssFiles).toContain('/_plugin_shop/css/shop.css?2.11.0'); + // An empty setting shows its default, as the site's settings files read it + expect(styles.inlineCSS).toBe( + '#pageEntries .cartPrice { color: #ff0000; font-family: Roboto; font-size: 12px; font-weight: bold; font-style: normal; font-variant: normal; line-height: 14px; }' + + '#shoppingCartTitle, #shoppingCartEmpty { font-family: Arial, sans-serif; font-weight: bold; font-style: normal; font-variant: normal; }', + ); + }); + + it('writes the cart link and price layout of a responsive, centered page', () => { + const styles = shopStyles({ responsive: 'yes', centeredContents: 'yes' }); + + expect(styles.inlineCSS).toContain( + '#shoppingCart { position: relative; float: right; right: inherit; margin: 0 10px 10px 10px; }#shoppingCart { margin-top: 20px; }', + ); + expect(styles.inlineCSS).toContain( + '@media (max-width: 767px) {.bt-auto-responsive #shoppingCart { margin-top: 20px; }', + ); + }); +}); diff --git a/editor/src/app/sites/sections/section-head-render.service.ts b/editor/src/app/sites/sections/section-head-render.service.ts index f3344244e..d08ef01bd 100644 --- a/editor/src/app/sites/sections/section-head-render.service.ts +++ b/editor/src/app/sites/sections/section-head-render.service.ts @@ -21,6 +21,8 @@ export class SectionHeadRenderService { isResponsive, isAutoResponsive, user, + siteTemplateConfig, + shopSettingsConfig, ) { // skip some template variables, no need for them in editor // title @@ -55,6 +57,8 @@ export class SectionHeadRenderService { shopSettings, isResponsive, isAutoResponsive, + siteTemplateConfig, + shopSettingsConfig, ), scripts: this.getScripts( appState, @@ -80,6 +84,8 @@ export class SectionHeadRenderService { shopSettings, isResponsive, isAutoResponsive, + siteTemplateConfig, + shopSettingsConfig, ) { let googleWebFonts = []; let templateSettings = siteTemplateSettings; @@ -126,11 +132,7 @@ export class SectionHeadRenderService { if (templateName === 'messy') { if (isShopAvailable) { - cssFiles.push( - `/_plugin_shop/css/shop.css.php?${cacheBoost}${ - siteSlug ? `&site=${siteSlug}` : '' - }`, - ); + cssFiles.push(`/_plugin_shop/css/shop.css?${appState.version}`); } if (isResponsive || isAutoResponsive) { @@ -158,6 +160,20 @@ export class SectionHeadRenderService { inlineCSS += '}'; } } + + if (isShopAvailable) { + inlineCSS += this.getShopCSS( + this.withDefaults( + templateSettings.group_price_item, + shopSettingsConfig.group_price_item, + ), + this.withDefaults( + siteTemplateSettings.entryHeading, + siteTemplateConfig?.entryHeading, + ), + siteTemplateSettings.pageLayout, + ); + } } return { @@ -168,6 +184,90 @@ export class SectionHeadRenderService { }; } + /** + * The shop's styles that depend on the site's settings, following the static `shop.css` + */ + getShopCSS(priceItem = {}, entryHeading = {}, pageLayout = {}) { + const isResponsive = pageLayout['responsive'] === 'yes'; + const isCentered = pageLayout['centeredContents'] === 'yes'; + + let css = this.getCSSRule('#pageEntries .cartPrice', { + color: priceItem['priceItemFontcolor'], + 'font-family': this.getFontFamily( + priceItem['priceItemgoogleFont'], + priceItem['priceItemfontFamily'], + ), + 'font-size': priceItem['priceItemfontSize'], + 'font-weight': priceItem['priceItemfontWeight'], + 'font-style': priceItem['priceItemfontStyle'], + 'font-variant': priceItem['priceItemfontVariant'], + 'line-height': priceItem['priceItemlineHeight'], + }); + css += this.getCSSRule('#shoppingCartTitle, #shoppingCartEmpty', { + 'font-family': this.getFontFamily( + entryHeading['googleFont'], + entryHeading['fontFamily'], + ), + 'font-weight': entryHeading['fontWeight'], + 'font-style': entryHeading['fontStyle'], + 'font-variant': entryHeading['fontVariant'], + }); + + if (isResponsive) { + css += + '#shoppingCart { position: relative; float: right; right: inherit; margin: 0 10px 10px 10px; }'; + + if (isCentered) { + css += '#shoppingCart { margin-top: 20px; }'; + css += + '#pageEntries .addToCart div, #pageEntries .addToCart div.cartPrice, #pageEntries .addToCart button.addToCartButton { float: none; }'; + } + } + + if (isCentered) { + css += '@media (max-width: 767px) {'; + css += '.bt-auto-responsive #shoppingCart { margin-top: 20px; }'; + css += + '.bt-auto-responsive #pageEntries .addToCart div, .bt-auto-responsive #pageEntries .addToCart div.cartPrice, .bt-auto-responsive #pageEntries .addToCart button.addToCartButton { float: none; }'; + css += '}'; + } + + return css; + } + + /** + * Settings with each empty value replaced by its default, the way the site's settings files are read + */ + withDefaults(values = {}, settingsConfig = {}) { + return Object.fromEntries( + Object.entries(values).map(([slug, value]) => [ + slug, + String(value ?? '').trim() === '' && settingsConfig[slug] + ? settingsConfig[slug].default + : value, + ]), + ); + } + + /** + * A Google font's family name, or else the chosen font family + */ + getFontFamily(googleFont = '', fontFamily = '') { + return googleFont.trim() !== '' ? googleFont.split(':')[0] : fontFamily; + } + + /** + * A CSS rule, leaving out the empty values + */ + getCSSRule(selector: string, declarations: { [property: string]: string }) { + const css = Object.entries(declarations) + .map(([property, value]) => [property, String(value ?? '').trim()]) + .filter(([, value]) => value !== '') + .map(([property, value]) => `${property}: ${value};`); + + return css.length ? `${selector} { ${css.join(' ')} }` : ''; + } + getScripts( appState, siteSlug, @@ -252,6 +352,8 @@ export class SectionHeadRenderService { isResponsive, isAutoResponsive, user, + siteTemplateConfig, + shopSettingsConfig, ) { const viewData = this.getViewData( appState, @@ -267,6 +369,8 @@ export class SectionHeadRenderService { isResponsive, isAutoResponsive, user, + siteTemplateConfig, + shopSettingsConfig, ); try { diff --git a/engine/_classes/class.bertatemplate.php b/engine/_classes/class.bertatemplate.php index b782dccca..0d2449e07 100644 --- a/engine/_classes/class.bertatemplate.php +++ b/engine/_classes/class.bertatemplate.php @@ -104,12 +104,6 @@ public function addContent($sectionName, $tagName) $isShopAvailable = isset($shopEnabled) && $shopEnabled; - if ($isShopAvailable) { - global $db; - // We need to initialize BertaShop here for correct migration order - new BertaShop($db); - } - $isEditMode = $this->environment == 'engine'; $isPreviewMode = ! empty(self::$options['PREVIEW_FOLDER']); $request = Request::capture(); From 793c0b95dc89deded9a677780f07f7e6a3e1dcd8 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Sat, 10 Oct 2026 19:34:19 +0300 Subject: [PATCH 02/18] Greeting text adjustments --- .../tests/Feature/ShopStorefrontControllerTest.php | 14 ++++++++++++++ _api_app/tests/Pest.php | 13 +++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php index dd5513166..e096c1549 100644 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -480,6 +480,20 @@ function capturesSent(): int Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller && $mail->hasTo('ann@buyer.test')); }); + it('thanks the customer for shopping with the site the page title names', function (array $texts, string $thanks) { + Mail::fake(); + writeShopSettings($this->bertaRoot, [], '', $texts); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller + && str_contains($mail->render(), "{$thanks}")); + })->with([ + 'with a page title' => [['pageTitle' => 'Riga Prints'], 'Thanks for shopping with Riga Prints!'], + 'without one' => [[], 'Thanks for shopping with us!'], + ]); + it('takes off at most the price of the products for a promo discount over 100%', function () { writeShopSettings($this->bertaRoot, ['promoCodeDiscount' => '150']); $token = fillShopBasket([[SHOP_CHAIR]]); diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index 86c9936e9..81a2fff87 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -107,8 +107,9 @@ function setUpShopTestRoot(): string /** * @param array $shop + * @param array $texts The site's texts, besides its owner's name */ -function writeShopSettings(string $bertaRoot, array $shop = [], string $site = ''): void +function writeShopSettings(string $bertaRoot, array $shop = [], string $site = '', array $texts = []): void { $shop = array_merge([ 'paymentMethod' => 'both', @@ -119,16 +120,16 @@ function writeShopSettings(string $bertaRoot, array $shop = [], string $site = ' 'promoCodeDiscount' => '10', ], $shop); - $values = implode('', array_map( + $xmlValues = fn (array $values) => implode('', array_map( fn ($name, $value) => "<{$name}>", - array_keys($shop), - $shop, + array_keys($values), + $values, )); $siteRoot = $bertaRoot . '/storage' . ($site !== '' ? "/-sites/{$site}" : ''); File::ensureDirectoryExists($siteRoot); File::put($siteRoot . '/settings.xml', '' - . "{$values}" - . '' + . '' . $xmlValues($shop) . '' + . '' . $xmlValues(array_merge(['ownerName' => 'Shop Owner'], $texts)) . '' . ''); } From 4f3deaa33b098ba2843437c5393b95d5049c0738 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Mon, 12 Oct 2026 14:45:39 +0300 Subject: [PATCH 03/18] Fix regional costs weight/price update path --- _api_app/tests/Feature/ShopControllerTest.php | 25 +++++ .../shop-regional-costs.state.spec.ts | 92 +++++++++++++++++++ .../shop-regional-costs.state.ts | 2 +- 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 editor/src/app/shop/regional-costs/shop-regional-costs.state.spec.ts diff --git a/_api_app/tests/Feature/ShopControllerTest.php b/_api_app/tests/Feature/ShopControllerTest.php index deef090be..62ef2df4c 100644 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ b/_api_app/tests/Feature/ShopControllerTest.php @@ -4,6 +4,7 @@ use App\Http\Middleware\SetupMiddleware; use App\Models\User; use App\Plugins\Shop\ShopSettingsDataService; +use App\Plugins\Shop\ShopShippingCostsDataService; use App\Plugins\Shop\ShopShippingRegionsDataService; use App\Sites\Settings\SiteSettingsDataService; use Illuminate\Support\Facades\File; @@ -145,6 +146,30 @@ expect((new ShopShippingRegionsDataService(''))->query()->value('name'))->toBe('Rock & "Roll"'); }); + it('updates the weight and price of the shipping cost the path names', function () { + $regions = new ShopShippingRegionsDataService(''); + $costs = new ShopShippingCostsDataService(''); + $regions->addRegion('Latvia', 21); + $regions->addRegion('Europe', 21); + $costs->addRegionCost(1, 500, 5); + $costs->addRegionCost(1, 1000, 8); + $costs->addRegionCost(2, 500, 10); + $costs->addRegionCost(2, 1000, 15); + + patchJson('http://shop.test' . route('regional-costs', [], false), ['path' => '/3/price', 'value' => '7.5']) + ->assertOk() + ->assertJsonPath('data.value', '7.5'); + patchJson('http://shop.test' . route('regional-costs', [], false), ['path' => '/4/weight', 'value' => '1500']) + ->assertOk(); + + expect($costs->query()->orderBy('id')->get(['id', 'id_region', 'weight', 'price'])->map(fn ($cost) => (array) $cost)->all())->toEqual([ + ['id' => 1, 'id_region' => 1, 'weight' => 500, 'price' => 5], + ['id' => 2, 'id_region' => 1, 'weight' => 1000, 'price' => 8], + ['id' => 3, 'id_region' => 2, 'weight' => 500, 'price' => 7.5], + ['id' => 4, 'id_region' => 2, 'weight' => 1500, 'price' => 15], + ]); + }); + it('numbers new regions and their costs after the existing ones', function () { (new ShopShippingRegionsDataService(''))->addRegion('Finland', 24); (new ShopShippingRegionsDataService(''))->query()->where('id', 1)->update(['id' => 7]); diff --git a/editor/src/app/shop/regional-costs/shop-regional-costs.state.spec.ts b/editor/src/app/shop/regional-costs/shop-regional-costs.state.spec.ts new file mode 100644 index 000000000..f04ba7367 --- /dev/null +++ b/editor/src/app/shop/regional-costs/shop-regional-costs.state.spec.ts @@ -0,0 +1,92 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions, StateContext, Store } from '@ngxs/store'; +import { of } from 'rxjs'; + +import { + ShopRegionalCostsModel, + ShopRegionalCostsState, +} from './shop-regional-costs.state'; +import { UpdateShopRegionCostAction } from './shop-regional-costs.actions'; +import { AppState } from '../../app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; +import { ShopStateService } from '../shop-state.service'; + +describe('ShopRegionalCostsState', () => { + // Cost ids that differ from their regions' ids, as in a shop with several regions + const REGIONS: ShopRegionalCostsModel = { + blog: [ + { + id: 1, + name: 'Latvia', + vat: 21, + costs: [ + { id: 3, weight: 500, price: 5 }, + { id: 4, weight: 1000, price: 8 }, + ], + }, + { + id: 2, + name: 'Europe', + vat: 21, + costs: [ + { id: 5, weight: 500, price: 10 }, + { id: 6, weight: 1000, price: 15 }, + ], + }, + ], + }; + + let sync: jasmine.Spy; + let patchState: jasmine.Spy; + let state: ShopRegionalCostsState; + + beforeEach(() => { + sync = jasmine + .createSpy('sync') + .and.returnValue(of({ message: '', data: { value: '7' } })); + patchState = jasmine.createSpy('patchState'); + const store = { + selectSnapshot: (selector: unknown) => + selector === AppState.getSite + ? 'blog' + : { regionalCosts: '/regional-costs' }, + } as unknown as Store; + + state = TestBed.runInInjectionContext( + () => + new ShopRegionalCostsState( + store, + { sync } as unknown as AppStateService, + {} as ShopStateService, + {} as Actions, + ), + ); + }); + + it("saves a cost's price to that cost, not to its region's id", () => { + const context = { + getState: () => REGIONS, + patchState, + } as unknown as StateContext; + + state + .updateShopRegionalCost( + context, + new UpdateShopRegionCostAction(2, 5, { field: 'price', value: '7' }), + ) + .subscribe(); + + expect(sync).toHaveBeenCalledWith( + '/regional-costs', + { path: 'blog/5/price', value: '7' }, + 'PATCH', + ); + + const updated = patchState.calls.mostRecent().args[0].blog; + expect(updated[1].costs).toEqual([ + { id: 5, weight: 500, price: '7' }, + { id: 6, weight: 1000, price: 15 }, + ]); + expect(updated[0]).toBe(REGIONS['blog'][0]); + }); +}); diff --git a/editor/src/app/shop/regional-costs/shop-regional-costs.state.ts b/editor/src/app/shop/regional-costs/shop-regional-costs.state.ts index 0ed8dadf0..f4c363363 100644 --- a/editor/src/app/shop/regional-costs/shop-regional-costs.state.ts +++ b/editor/src/app/shop/regional-costs/shop-regional-costs.state.ts @@ -258,7 +258,7 @@ export class ShopRegionalCostsState implements NgxsOnInit { .sync( syncURLs.regionalCosts, { - path: `${site}/${action.id}/${action.payload.field}`, + path: `${site}/${action.costId}/${action.payload.field}`, value: action.payload.value, }, 'PATCH', From d5aa9ce50536999255791349c28f49f66136140b Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Mon, 12 Oct 2026 23:05:49 +0300 Subject: [PATCH 04/18] More tests for shop plugin --- .../Feature/ShopStorefrontControllerTest.php | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php index e096c1549..4215c223f 100644 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -12,10 +12,12 @@ use Illuminate\Database\Connection; use Illuminate\Database\QueryException; use Illuminate\Http\Client\Request as HttpRequest; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; use Illuminate\Testing\TestResponse; use Symfony\Component\Mailer\Exception\TransportException; use Tests\TestCase; @@ -25,6 +27,7 @@ use function Pest\Laravel\getJson; use function Pest\Laravel\postJson; use function Pest\Laravel\travel; +use function Pest\Laravel\travelTo; use function Pest\Laravel\withCredentials; use function Pest\Laravel\withoutMiddleware; @@ -61,6 +64,7 @@ // SetupMiddleware reconciles the real .env file on every request. withoutMiddleware(SetupMiddleware::class); Http::preventStrayRequests(); + Storage::fake('local'); }); afterEach(function () { @@ -480,6 +484,21 @@ function capturesSent(): int Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller && $mail->hasTo('ann@buyer.test')); }); + it('numbers each order by its date and basket, also for orders placed in the same second', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + $firstToken = fillShopBasket([[SHOP_CHAIR]]); + $secondToken = fillShopBasket([[SHOP_TSHIRT, 'S']]); + + $numbers = [ + shopCheckout($firstToken)->assertOk()->json('data.order_id'), + shopCheckout($secondToken)->assertOk()->json('data.order_id'), + ]; + + expect($numbers)->toBe(['20261012-1', '20261012-2']) + ->and(shopDb()->table('client')->orderBy('id')->pluck('order_id')->all())->toBe($numbers); + }); + it('thanks the customer for shopping with the site the page title names', function (array $texts, string $thanks) { Mail::fake(); writeShopSettings($this->bertaRoot, [], '', $texts); @@ -561,6 +580,27 @@ function capturesSent(): int shopCheckout('unknown-token')->assertGone(); }); + it('starts a new basket for a cookie holding the placed order marker, leaving the orders as they were', function (int $minutes) { + Mail::fake(); + shopCheckout(fillShopBasket([[SHOP_CHAIR]]))->assertOk(); + $order = shopDb()->table('client')->where('session_id', 'checkout')->first(); + travel($minutes)->minutes(); + + $token = addToShopBasket(SHOP_TSHIRT, 'S', 'checkout') + ->assertOk() + ->getCookie(ShopBasketService::clientCookie(''), false) + ->getValue(); + + expect($token)->not->toBe('checkout')->toHaveLength(32); + expect(shopDb()->table('client')->find($order->id)) + ->session_id->toBe('checkout') + ->name->toBe('Ann Buyer'); + expect(shopDb()->table('orders')->where('id_client', $order->id)->count())->toBe(1); + })->with([ + 'within the basket lifetime' => 0, + 'after it' => 31, + ]); + it('rejects a payment method the shop does not accept', function () { writeShopSettings($this->bertaRoot, ['paymentMethod' => 'paypal', ...nvpSettings()]); $token = fillShopBasket([[SHOP_CHAIR]]); @@ -728,9 +768,11 @@ function capturesSent(): int $response = returnFromPaypal($paypalToken)->assertRedirect('http://shop.test/?shop_payment=paid'); - expect(shopDb()->table('client')->where('session_id', 'checkout')->first())->paypal->toEqual(1)->price_total->toEqual(105); + $order = shopDb()->table('client')->where('session_id', 'checkout')->first(); + expect($order)->paypal->toEqual(1)->price_total->toEqual(105); + Storage::disk('local')->assertExists("shop/paypal/{$paypalToken}.json"); + expect(Storage::disk('local')->json("shop/paypal/{$paypalToken}.json"))->order_id->toBe($order->order_id); expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) - ->and(File::exists("{$this->bertaRoot}/_plugin_shop/data/{$paypalToken}"))->toBeTrue() ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue() ->and($response->getCookie('shopping_cart', false)->isCleared())->toBeTrue(); Mail::assertSent(ShopOrderMail::class, 2); @@ -748,8 +790,8 @@ function capturesSent(): int returnFromPaypal($api === 'nvp' ? SHOP_NVP_TOKEN : SHOP_REST_TOKEN) ->assertRedirect('http://shop.test/?shop_payment=failed'); - expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() - ->and(File::exists("{$this->bertaRoot}/_plugin_shop/data"))->toBeFalse(); + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); + Storage::disk('local')->assertMissing('shop/paypal/' . ($api === 'nvp' ? SHOP_NVP_TOKEN : SHOP_REST_TOKEN) . '.json'); Mail::assertNothingSent(); })->with(['nvp', 'rest']); From a48ab6bac63b14c91d837522b5d8ab42d52c7397 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 13 Oct 2026 09:28:36 +0300 Subject: [PATCH 05/18] More tests for shop plugin --- .../Feature/ShopStorefrontControllerTest.php | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php index 4215c223f..77ae5281c 100644 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -18,6 +18,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Sleep; use Illuminate\Testing\TestResponse; use Symfony\Component\Mailer\Exception\TransportException; use Tests\TestCase; @@ -809,6 +810,38 @@ function capturesSent(): int ->toEqual(['Chair' => 1, 'T-shirt S' => 0]); }); + it('reserves the products again when their reservation expired while the customer was at PayPal', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + travel(31)->minutes(); + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]])->assertJsonPath('data.0.instock', 1); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) + ->and(shopDb()->table('product')->where('uniqid', SHOP_CHAIR)->value('instock'))->toEqual(0); + }); + + it('takes no payment for products sold while the customer was at PayPal', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + travel(31)->minutes(); + addToShopBasket(SHOP_CHAIR)->assertOk(); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect('http://shop.test/?shop_payment=failed'); + + expect(capturesSent())->toBe(0) + ->and(shopDb()->table('client')->where('session_id', 'checkout')->exists())->toBeFalse() + ->and(shopDb()->table('orders')->count())->toBe(1); + Mail::assertNothingSent(); + }); + it('captures nothing for a payment it did not start', function () { writeShopSettings($this->bertaRoot, nvpSettings()); fakeNvp(); @@ -989,6 +1022,36 @@ function capturesSent(): int Mail::assertSent(ShopOrderMail::class, 2); }); + it('takes no second payment for a basket approved twice at once', function () { + Mail::fake(); + Sleep::fake(syncWithCarbon: true); + writeShopSettings($this->bertaRoot, restSettings()); + $order = fn (string $id) => ['id' => $id, 'status' => 'CREATED', 'links' => [['rel' => 'approve', 'href' => "https://www.paypal.com/checkoutnow?token={$id}"]]]; + $cardCapture = null; + Http::fake([ + 'api-m.paypal.com/v1/oauth2/token' => Http::response(['access_token' => 'A21AA', 'expires_in' => 32400]), + 'api-m.paypal.com/v2/checkout/orders' => Http::sequence()->push($order('CARDORDER1'))->push($order('PAYPALORDER1')), + 'api-m.paypal.com/v2/checkout/orders/*/capture' => function (HttpRequest $request) use (&$cardCapture) { + if (str_contains($request->url(), 'PAYPALORDER1')) { + // The customer approves the card form's payment while PayPal takes the popup's + $cardCapture = postJson(storefrontUrl('paypal/capture'), ['token' => 'CARDORDER1']); + } + + return Http::response(paidRestOrder()); + }, + ]); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true])->assertJsonPath('data.token', 'CARDORDER1'); + shopCheckout($token, ['payment_method' => 'paypal', 'paypal_buttons' => true])->assertJsonPath('data.token', 'PAYPALORDER1'); + + postJson(storefrontUrl('paypal/capture'), ['token' => 'PAYPALORDER1'])->assertJsonPath('data.status', 'paid'); + + $cardCapture->assertJsonPath('data.status', 'unconfirmed'); + // Approved again, the card payment finds the basket paid + postJson(storefrontUrl('paypal/capture'), ['token' => 'CARDORDER1'])->assertJsonPath('data.status', 'failed'); + expect(capturesSent())->toBe(1); + }); + it('captures nothing for a malformed token', function () { writeShopSettings($this->bertaRoot, restSettings()); fakeRest(); @@ -1009,6 +1072,43 @@ function capturesSent(): int expect(shopDb()->table('product')->pluck('name')->sort()->values()->all())->toBe(['Chair', 'T-shirt M', 'T-shirt S']); }); +it('lists only the paid products of an order for the shop admin', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + addToShopBasket(SHOP_TSHIRT, 'S', $token)->assertOk(); + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + actingAs(new User); + + $orders = getJson('http://shop.test' . route('orders', [], false))->assertOk()->json('data')['']; + + expect($orders)->toHaveCount(1) + ->and(array_column($orders[0]['orders'], 'name'))->toBe(['Chair']); +}); + +it('prices products in whole cents, as the site shows them, so PayPal takes the order', function () { + $blog = $this->bertaRoot . '/storage/blog.shop.xml'; + File::put($blog, str_replace('', '', File::get($blog))); + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]]); + shopDb()->table('product')->where('uniqid', SHOP_CHAIR)->update(['instock' => 3]); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR], [SHOP_CHAIR], [SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + Http::assertSent(function (HttpRequest $request) { + $purchase = $request['purchase_units'][0] ?? null; + + return $purchase + && $purchase['items'][0]['unit_amount']['value'] === '33.33' + && $purchase['amount']['breakdown']['item_total']['value'] === '99.99' + && $purchase['amount']['value'] === '109.99'; + }); +}); + it('adds the columns of newer shop versions to an older shop database', function () { $database = $this->bertaRoot . '/storage/old-shop-db.sqlite'; $db = new SQLite3($database); From 10023dc61af1b97c0d8a497d81bc7d0b0174be90 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 13 Oct 2026 10:42:42 +0300 Subject: [PATCH 06/18] Paginate orders list. Refresh list option. --- _api_app/tests/Feature/ShopControllerTest.php | 87 ++++++ .../app/shop/orders/shop-orders.actions.ts | 5 + .../app/shop/orders/shop-orders.component.ts | 263 +++++++++++------- .../app/shop/orders/shop-orders.state.spec.ts | 60 ++++ .../src/app/shop/orders/shop-orders.state.ts | 56 +++- .../sites/shared/icon-refresh.component.ts | 25 ++ .../app/sites/shared/sites-shared.module.ts | 3 + editor/src/styles/_shop.scss | 58 ++++ 8 files changed, 448 insertions(+), 109 deletions(-) create mode 100644 editor/src/app/shop/orders/shop-orders.state.spec.ts create mode 100644 editor/src/app/sites/shared/icon-refresh.component.ts diff --git a/_api_app/tests/Feature/ShopControllerTest.php b/_api_app/tests/Feature/ShopControllerTest.php index 62ef2df4c..aa37a4694 100644 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ b/_api_app/tests/Feature/ShopControllerTest.php @@ -3,10 +3,14 @@ use App\Http\Middleware\Authenticate; use App\Http\Middleware\SetupMiddleware; use App\Models\User; +use App\Plugins\Shop\ShopClientsDataService; +use App\Plugins\Shop\ShopOrdersDataService; +use App\Plugins\Shop\ShopProductsDataService; use App\Plugins\Shop\ShopSettingsDataService; use App\Plugins\Shop\ShopShippingCostsDataService; use App\Plugins\Shop\ShopShippingRegionsDataService; use App\Sites\Settings\SiteSettingsDataService; +use Carbon\Carbon; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; @@ -199,3 +203,86 @@ ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(5); }); }); + +/** + * Checked-out orders ORD-1 to ORD-{$count}, each a minute after the previous one unless they share + * $createdAt and each with a paid poster, plus a newer basket that isn't an order yet + */ +function placeShopOrders(int $count, ?string $createdAt = null): void +{ + $clients = new ShopClientsDataService(''); + $orders = new ShopOrdersDataService(''); + (new ShopProductsDataService(''))->query()->insert(['id' => 1, 'uniqid' => 'poster', 'name' => 'Poster']); + + foreach (range(1, $count) as $id) { + $placedAt = $createdAt ?? Carbon::create(2026, 1, 1)->addMinutes($id)->toDateTimeString(); + $clients->query()->insert(['id' => $id, 'created_at' => $placedAt, 'session_id' => 'checkout', 'order_id' => "ORD-{$id}"]); + $orders->query()->insert(['id' => $id, 'created_at' => $placedAt, 'id_client' => $id, 'id_product' => 1, 'paid' => 1, 'price' => 10]); + } + + $clients->query()->insert(['id' => $count + 1, 'created_at' => '2027-01-01 00:00:00', 'session_id' => str_repeat('a', 32)]); +} + +/** + * @return array{orders: list>, page: int, lastPage: int, total: int} + */ +function getShopOrdersPage(int $page): array +{ + return getJson('http://shop.test' . route('orders', [], false) . "?site=&page={$page}") + ->assertOk() + ->json('data')['']; +} + +describe('orders', function () { + beforeEach(function () { + $this->bertaRoot = setUpShopTestRoot(); + actingAs(new User); + }); + + afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } + }); + + it('sends the newest orders as the first page with the shop state, without baskets', function () { + placeShopOrders(25); + + $firstPage = getJson('http://shop.test' . route('shop-state', [], false) . '?site=') + ->assertOk() + ->json('data.orders')['']; + + expect($firstPage)->toMatchArray(['page' => 1, 'lastPage' => 2, 'total' => 25]) + ->and(array_column($firstPage['orders'], 'order_id'))->toBe(array_map(fn (int $id) => "ORD-{$id}", range(25, 6))); + }); + + it('returns the requested page of orders with their paid products', function () { + placeShopOrders(25); + + $secondPage = getShopOrdersPage(2); + + expect($secondPage)->toMatchArray(['page' => 2, 'lastPage' => 2, 'total' => 25]) + ->and(array_column($secondPage['orders'], 'order_id'))->toBe(['ORD-5', 'ORD-4', 'ORD-3', 'ORD-2', 'ORD-1']) + ->and($secondPage['orders'][0]['orders'])->toEqual([['id_client' => 5, 'name' => 'Poster', 'qty' => 1, 'sum_total' => 10]]); + }); + + it('returns the last page for a page past the end', function () { + placeShopOrders(25); + + $page = getShopOrdersPage(9); + + expect($page['page'])->toBe(2) + ->and(array_column($page['orders'], 'order_id'))->toBe(['ORD-5', 'ORD-4', 'ORD-3', 'ORD-2', 'ORD-1']); + }); + + it('pages orders placed in the same second without repeating or skipping any', function () { + placeShopOrders(25, '2026-01-01 12:00:00'); + + $ids = [ + ...array_column(getShopOrdersPage(1)['orders'], 'id'), + ...array_column(getShopOrdersPage(2)['orders'], 'id'), + ]; + + expect($ids)->toBe(range(25, 1)); + }); +}); diff --git a/editor/src/app/shop/orders/shop-orders.actions.ts b/editor/src/app/shop/orders/shop-orders.actions.ts index 15f6bf0b7..c24f615f9 100644 --- a/editor/src/app/shop/orders/shop-orders.actions.ts +++ b/editor/src/app/shop/orders/shop-orders.actions.ts @@ -17,6 +17,11 @@ export class InitShopOrdersAction { constructor(public payload: any) {} } +export class LoadShopOrdersPageAction { + static readonly type = 'SHOP_ORDERS:LOAD_PAGE'; + constructor(public page: number) {} +} + /* Reset: */ export class ResetShopOrdersAction { public static type = 'SHOP_ORDERS:RESET'; diff --git a/editor/src/app/shop/orders/shop-orders.component.ts b/editor/src/app/shop/orders/shop-orders.component.ts index 0d6680205..b501bb952 100644 --- a/editor/src/app/shop/orders/shop-orders.component.ts +++ b/editor/src/app/shop/orders/shop-orders.component.ts @@ -1,142 +1,195 @@ -import { Component } from '@angular/core'; +import { Component, ElementRef } from '@angular/core'; import { Store } from '@ngxs/store'; import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; import { stringToCurrency } from '../../shared/helpers'; import { ShopSettingsState } from '../settings/shop-settings.state'; -import { ShopOrdersState } from './shop-orders.state'; +import { LoadShopOrdersPageAction } from './shop-orders.actions'; +import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; @Component({ selector: 'berta-shop-orders', template: ` - @for (order of orders$ | async; track order) { -
-

- Order ID - {{ order.order_id }} -

-

- Created {{ order.created_at }} -

- @if (order.company) { + @if (ordersPage$ | async; as ordersPage) { +
+

{{ ordersPage.total }} orders

+ +
+ @for (order of ordersPage.orders; track order.id) { +

- Company {{ order.company }} + Order ID + {{ order.order_id }}

- } - @if (order.company_reg_no) {

- Company reg. no. {{ order.company_reg_no }} + Created {{ order.created_at }}

- } - @if (order.legal_address) { + @if (order.company) { +

+ Company {{ order.company }} +

+ } + @if (order.company_reg_no) { +

+ Company reg. no. {{ order.company_reg_no }} +

+ } + @if (order.legal_address) { +

+ Legal address {{ order.legal_address }} +

+ }

- Legal address {{ order.legal_address }} + Name {{ order.name }}

- } -

- Name {{ order.name }} -

-

- Phone {{ order.phone }} -

-

- Address {{ order.address }} -

-

- Email {{ order.email }} -

- @if ( - order.ship_name || - order.ship_phone || - order.ship_address || - order.ship_email || - order.ship_notes - ) {

- Shipping details - - @if (order.ship_name) { - {{ order.ship_name }}
- } - @if (order.ship_phone) { - {{ order.ship_phone }}
- } - @if (order.ship_address) { - {{ order.ship_address }}
- } - @if (order.ship_email) { - {{ order.ship_email }}
- } - @if (order.ship_notes) { - {{ order.ship_notes }}
- } -
+ Phone {{ order.phone }}

- } - @if (order.send_news) { -

Send news Yes

- } - @if (order.promo_code) {

- Promo code {{ order.promo_code }} + Address {{ order.address }}

- } -

- VAT (%) {{ order.vat }} -

-

- Shipment - {{ currency$ | async }} - {{ stringToCurrency(order.shipping) }} -

-

- Total - - + Email {{ order.email }} +

+ @if ( + order.ship_name || + order.ship_phone || + order.ship_address || + order.ship_email || + order.ship_notes + ) { +

+ Shipping details + + @if (order.ship_name) { + {{ order.ship_name }}
+ } + @if (order.ship_phone) { + {{ order.ship_phone }}
+ } + @if (order.ship_address) { + {{ order.ship_address }}
+ } + @if (order.ship_email) { + {{ order.ship_email }}
+ } + @if (order.ship_notes) { + {{ order.ship_notes }}
+ } +
+

+ } + @if (order.send_news) { +

Send news Yes

+ } + @if (order.promo_code) { +

+ Promo code {{ order.promo_code }} +

+ } +

+ VAT (%) {{ order.vat }} +

+

+ Shipment + {{ currency$ | async }} - {{ stringToCurrency(order.price_total) }} - @if (order.paypal) { - (PayPal) - } - -

-
- @if (order.orders && order.orders.length) { - - - - - - - @for (item of order.orders; track item) { +

+

+ Total + + {{ currency$ | async }} + {{ stringToCurrency(order.price_total) }} + @if (order.paypal) { + (PayPal) + } + +

+
+ @if (order.orders && order.orders.length) { +
Item nameQtyPrice ({{ currency$ | async }})
- - - + + + - } -
{{ item.name }}{{ item.qty }}{{ stringToCurrency(item.sum_total) }}Item nameQtyPrice ({{ currency$ | async }})
- } + @for (item of order.orders; track item) { + + {{ item.name }} + {{ item.qty }} + {{ stringToCurrency(item.sum_total) }} + + } + + } +
-
+ } + @if (ordersPage.lastPage > 1) { +
+ +

Page {{ ordersPage.page }} of {{ ordersPage.lastPage }}

+ +
+ } } `, standalone: false, }) export class ShopOrdersComponent { - orders$: Observable; + ordersPage$: Observable; currency$: Observable; + isLoading = false; - constructor(private store: Store) { - this.orders$ = this.store.select(ShopOrdersState.getCurrentSiteOrders); + constructor( + private store: Store, + private elementRef: ElementRef, + ) { + this.ordersPage$ = this.store.select( + ShopOrdersState.getCurrentSiteOrdersPage, + ); this.currency$ = this.store.select(ShopSettingsState.getCurrentCurrency); } + goToPage(page: number) { + this.isLoading = true; + this.store + .dispatch(new LoadShopOrdersPageAction(page)) + .pipe(finalize(() => (this.isLoading = false))) + .subscribe(() => { + this.elementRef.nativeElement.scrollIntoView({ block: 'start' }); + }); + } + stringToCurrency(price) { return stringToCurrency(price); } diff --git a/editor/src/app/shop/orders/shop-orders.state.spec.ts b/editor/src/app/shop/orders/shop-orders.state.spec.ts new file mode 100644 index 000000000..d42d457ac --- /dev/null +++ b/editor/src/app/shop/orders/shop-orders.state.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions, StateContext, Store } from '@ngxs/store'; +import { of } from 'rxjs'; + +import { + ShopOrdersModel, + ShopOrdersPage, + ShopOrdersState, +} from './shop-orders.state'; +import { LoadShopOrdersPageAction } from './shop-orders.actions'; +import { AppState } from '../../app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; +import { ShopStateService } from '../shop-state.service'; + +describe('ShopOrdersState', () => { + const SECOND_PAGE: ShopOrdersPage = { + orders: [{ id: 5, order_id: 'ORD-5', orders: [] }], + page: 2, + lastPage: 2, + total: 21, + }; + + let sync: jasmine.Spy; + let patchState: jasmine.Spy; + let state: ShopOrdersState; + + beforeEach(() => { + sync = jasmine + .createSpy('sync') + .and.returnValue(of({ message: '', data: { blog: SECOND_PAGE } })); + patchState = jasmine.createSpy('patchState'); + const store = { + selectSnapshot: (selector: unknown) => + selector === AppState.getSite ? 'blog' : { orders: '/orders' }, + } as unknown as Store; + + state = TestBed.runInInjectionContext( + () => + new ShopOrdersState( + store, + {} as ShopStateService, + { sync } as unknown as AppStateService, + {} as Actions, + ), + ); + }); + + it("replaces the current site's orders with the requested page", () => { + const context = { patchState } as unknown as StateContext; + + state.loadOrdersPage(context, new LoadShopOrdersPageAction(2)).subscribe(); + + expect(sync).toHaveBeenCalledWith( + '/orders', + { site: 'blog', page: 2 }, + 'GET', + ); + expect(patchState).toHaveBeenCalledWith({ blog: SECOND_PAGE }); + }); +}); diff --git a/editor/src/app/shop/orders/shop-orders.state.ts b/editor/src/app/shop/orders/shop-orders.state.ts index eea9bd809..0e9c7833a 100644 --- a/editor/src/app/shop/orders/shop-orders.state.ts +++ b/editor/src/app/shop/orders/shop-orders.state.ts @@ -1,5 +1,13 @@ import { startWith } from 'rxjs'; -import { take, filter, switchMap, distinct, map } from 'rxjs/operators'; +import { + take, + filter, + switchMap, + distinct, + map, + tap, + catchError, +} from 'rxjs/operators'; import { State, StateContext, @@ -11,20 +19,31 @@ import { ofActionSuccessful, } from '@ngxs/store'; import { ShopStateService } from '../shop-state.service'; +import { ShopState } from '../shop.state'; import { AppState } from '../../app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; import { RenameShopOrdersSiteAction, DeleteShopOrdersSiteAction, ResetShopOrdersAction, InitShopOrdersAction, + LoadShopOrdersPageAction, } from './shop-orders.actions'; +import { HttpErrorResponse } from '@angular/common/http'; import { DestroyRef, inject, Injectable } from '@angular/core'; import { combineLatest } from 'rxjs'; import { SwapContentsSitesAction } from 'src/app/sites/sites-state/sites.actions'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -interface ShopOrdersModel { - [site: string]: any[]; +export interface ShopOrdersPage { + orders: any[]; + page: number; + lastPage: number; + total: number; +} + +export interface ShopOrdersModel { + [site: string]: ShopOrdersPage; } const defaultState: ShopOrdersModel = {}; @@ -36,7 +55,10 @@ const defaultState: ShopOrdersModel = {}; @Injectable() export class ShopOrdersState implements NgxsOnInit { @Selector([ShopOrdersState, AppState.getSite]) - static getCurrentSiteOrders(state: ShopOrdersModel, site: string) { + static getCurrentSiteOrdersPage( + state: ShopOrdersModel, + site: string, + ): ShopOrdersPage { return state[site]; } @@ -46,6 +68,7 @@ export class ShopOrdersState implements NgxsOnInit { constructor( private store$: Store, private stateService: ShopStateService, + private appStateService: AppStateService, private actions$: Actions, ) {} @@ -89,6 +112,31 @@ export class ShopOrdersState implements NgxsOnInit { patchState(action.payload); } + @Action(LoadShopOrdersPageAction) + loadOrdersPage( + { patchState }: StateContext, + action: LoadShopOrdersPageAction, + ) { + const site = this.store$.selectSnapshot(AppState.getSite); + const syncURLs = this.store$.selectSnapshot(ShopState.getURLs); + + return this.appStateService + .sync(syncURLs.orders, { site, page: action.page }, 'GET') + .pipe( + tap((response: { message: string; data: ShopOrdersModel }) => { + patchState({ [site]: response.data[site] }); + }), + catchError((error: HttpErrorResponse | Error) => { + if (error instanceof HttpErrorResponse) { + console.error(error.error.message); + } else { + console.error(error.message); + } + throw error; + }), + ); + } + @Action(RenameShopOrdersSiteAction) renameOrdersSite( { setState, getState }: StateContext, diff --git a/editor/src/app/sites/shared/icon-refresh.component.ts b/editor/src/app/sites/shared/icon-refresh.component.ts new file mode 100644 index 000000000..fca33922a --- /dev/null +++ b/editor/src/app/sites/shared/icon-refresh.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'bt-icon-refresh', + template: ` + + + + + `, + standalone: false, +}) +export class IconRefreshComponent {} diff --git a/editor/src/app/sites/shared/sites-shared.module.ts b/editor/src/app/sites/shared/sites-shared.module.ts index 6b3b9816b..2eb3933cc 100644 --- a/editor/src/app/sites/shared/sites-shared.module.ts +++ b/editor/src/app/sites/shared/sites-shared.module.ts @@ -28,6 +28,7 @@ import { FilesInputComponent } from '../../../app/inputs/files-input.component'; import { IconMoveComponent } from './icon-move.component'; import { IconCropComponent } from './icon-crop.component'; import { IconSwapComponent } from './icon-switch.component'; +import { IconRefreshComponent } from './icon-refresh.component'; @NgModule({ imports: [ @@ -62,6 +63,7 @@ import { IconSwapComponent } from './icon-switch.component'; IconPublishComponent, IconMoveComponent, IconSwapComponent, + IconRefreshComponent, HelpTooltipComponent, ], exports: [ @@ -88,6 +90,7 @@ import { IconSwapComponent } from './icon-switch.component'; IconPublishComponent, IconMoveComponent, IconSwapComponent, + IconRefreshComponent, HelpTooltipComponent, ], }) diff --git a/editor/src/styles/_shop.scss b/editor/src/styles/_shop.scss index dc24a5952..5e8d7015b 100644 --- a/editor/src/styles/_shop.scss +++ b/editor/src/styles/_shop.scss @@ -215,4 +215,62 @@ berta-shop-orders { padding-left: 0; } } + + .bt-sh-orders-toolbar { + align-items: center; + border-bottom: 1px solid variables.$grey-10; + display: flex; + justify-content: space-between; + margin: 1.3em; + padding-bottom: 1em; + + p { + color: variables.$grey-40; + margin: 0; + } + + button { + align-items: center; + background: none; + color: variables.$black; + cursor: pointer; + display: flex; + font-family: inherit; + font-size: variables.$input-font-size; + gap: 0.4em; + padding: 0; + transition: color 0.2s; + + svg { + display: block; + height: 1.15em; + width: 1.15em; + + path { + fill: currentColor; + } + } + + &:hover { + color: variables.$berta-accent; + } + + &[disabled] { + color: variables.$grey-40; + cursor: default; + } + } + } + + .bt-sh-orders-pager { + align-items: center; + display: flex; + justify-content: space-between; + margin: 1.3em; + + p { + margin: 0 0.5em; + text-align: center; + } + } } From 901f9f2aed6ee0194e67132bcc54d89571e3dd21 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 13 Oct 2026 12:49:17 +0300 Subject: [PATCH 07/18] Order status with search filter --- _api_app/tests/Feature/ShopControllerTest.php | 115 ++++++++++++- .../Feature/ShopStorefrontControllerTest.php | 9 +- .../src/app/inputs/select-input.component.ts | 18 +- .../app/shop/orders/shop-orders.actions.ts | 13 +- .../app/shop/orders/shop-orders.component.ts | 113 ++++++++++--- .../app/shop/orders/shop-orders.state.spec.ts | 53 +++++- .../src/app/shop/orders/shop-orders.state.ts | 57 ++++++- editor/src/styles/_shop.scss | 155 +++++++++++++++++- 8 files changed, 491 insertions(+), 42 deletions(-) diff --git a/_api_app/tests/Feature/ShopControllerTest.php b/_api_app/tests/Feature/ShopControllerTest.php index aa37a4694..c84b5aeff 100644 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ b/_api_app/tests/Feature/ShopControllerTest.php @@ -54,6 +54,7 @@ 'products' => ['GET', '/products'], 'product update' => ['PATCH', '/products'], 'orders' => ['GET', '/orders'], + 'order update' => ['PATCH', '/orders'], 'regional costs' => ['GET', '/regional-costs'], 'region add' => ['POST', '/region'], 'region update' => ['PATCH', '/region'], @@ -200,7 +201,7 @@ $regions = new ShopShippingRegionsDataService(''); expect($regions->query()->orderBy('id')->pluck('name')->all())->toBe(['Rock & "Roll"', 'Plain']) - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(5); + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(6); }); }); @@ -224,15 +225,35 @@ function placeShopOrders(int $count, ?string $createdAt = null): void } /** - * @return array{orders: list>, page: int, lastPage: int, total: int} + * @return array{orders: list>, page: int, lastPage: int, total: int, statusFilter: string|null} */ -function getShopOrdersPage(int $page): array +function getShopOrdersPage(int $page, ?string $status = null): array { - return getJson('http://shop.test' . route('orders', [], false) . "?site=&page={$page}") + $statusQuery = $status === null ? '' : "&status={$status}"; + + return getJson('http://shop.test' . route('orders', [], false) . "?site=&page={$page}{$statusQuery}") ->assertOk() ->json('data')['']; } +/** + * @param array> $columnsById + */ +function updateShopOrders(array $columnsById): void +{ + foreach ($columnsById as $id => $columns) { + (new ShopClientsDataService(''))->query()->where('id', $id)->update($columns); + } +} + +/** + * @return array + */ +function shopOrderStatuses(): array +{ + return (new ShopClientsDataService(''))->query()->orderBy('id')->pluck('status', 'id')->all(); +} + describe('orders', function () { beforeEach(function () { $this->bertaRoot = setUpShopTestRoot(); @@ -285,4 +306,90 @@ function getShopOrdersPage(int $page): array expect($ids)->toBe(range(25, 1)); }); + + it('pages only the orders with the filtered status', function () { + placeShopOrders(25); + updateShopOrders(array_fill_keys(range(1, 21), ['status' => 'shipped']) + [22 => ['status' => 'paid']]); + + $shippedPage = getShopOrdersPage(2, 'shipped'); + + expect($shippedPage)->toMatchArray(['page' => 2, 'lastPage' => 2, 'total' => 21, 'statusFilter' => 'shipped']) + ->and(array_column($shippedPage['orders'], 'order_id'))->toBe(['ORD-1']) + ->and(array_column(getShopOrdersPage(1, 'paid')['orders'], 'order_id'))->toBe(['ORD-22']); + }); + + it('finds the orders without a status, leaving out baskets', function () { + placeShopOrders(3); + updateShopOrders([2 => ['status' => 'paid']]); + + $page = getShopOrdersPage(1, 'none'); + + expect($page)->toMatchArray(['total' => 2, 'statusFilter' => 'none']) + ->and(array_column($page['orders'], 'order_id'))->toBe(['ORD-3', 'ORD-1']); + }); + + it('returns 422 for an unknown status filter', function () { + getJson('http://shop.test' . route('orders', [], false) . '?site=&status=lost') + ->assertUnprocessable() + ->assertJsonValidationErrors(['status' => 'The selected status is invalid.']); + }); + + it('sets the status of an order', function (string $status) { + placeShopOrders(2); + + patchJson('http://shop.test' . route('orders', [], false), ['path' => '/2/status', 'value' => $status]) + ->assertOk() + ->assertJsonPath('data', ['site' => '', 'path' => '2/status', 'value' => $status]); + + expect(shopOrderStatuses())->toBe([1 => null, 2 => $status, 3 => null]); + })->with(['awaiting_payment', 'paid', 'shipped', 'cancelled', 'refunded']); + + it('keeps a PayPal order from awaiting payment', function () { + placeShopOrders(1); + updateShopOrders([1 => ['paypal' => 1, 'status' => 'paid']]); + + patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/status', 'value' => 'awaiting_payment']) + ->assertStatus(400) + ->assertJsonPath('message', 'A PayPal order is already paid!'); + + expect(shopOrderStatuses()[1])->toBe('paid'); + }); + + it('returns 404 for a status change of no order', function (string $path, string $message) { + placeShopOrders(1); + + patchJson('http://shop.test' . route('orders', [], false), ['path' => $path, 'value' => 'paid']) + ->assertNotFound() + ->assertJsonPath('message', $message); + + expect(shopOrderStatuses())->toBe([1 => null, 2 => null]); + })->with([ + 'a basket' => ['/2/status', 'Order not found!'], + 'a missing order' => ['/9/status', 'Order not found!'], + 'an unknown site' => ['nosite/1/status', 'Site not found!'], + ]); + + it('returns 422 for a status change of another field or to an unknown status', function () { + placeShopOrders(1); + + patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/order_id', 'value' => 'lost']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['path', 'value']); + + expect(shopOrderStatuses())->toBe([1 => null, 2 => null]); + }); + + it('adds an empty status to the orders of an older shop version', function () { + writeShopSettings($this->bertaRoot, ['version' => '5']); + $database = new SQLite3($this->bertaRoot . '/storage/shop-db.sqlite'); + $database->exec('CREATE TABLE "client" ("id" integer not null, "created_at" datetime not null, "session_id" varchar not null, "order_id" varchar, primary key ("id"))'); + $database->exec("INSERT INTO client (created_at, session_id, order_id) VALUES ('2026-01-01 00:00:00', 'checkout', 'ORD-1')"); + $database->close(); + + $page = getShopOrdersPage(1); + + expect($page['orders'])->toHaveCount(1) + ->and($page['orders'][0])->toHaveKey('status', null) + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(6); + }); }); diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php index 77ae5281c..fb34e3fa7 100644 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -473,7 +473,8 @@ function capturesSent(): int ->price_total->toEqual(122.5) ->shipping->toEqual(10) ->vat->toEqual(21) - ->paypal->toEqual(0); + ->paypal->toEqual(0) + ->status->toBe('awaiting_payment'); expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(2) ->and(shopDb()->table('product')->sum('reservation'))->toEqual(0) ->and($response->getCookie(ShopBasketService::clientCookie(''), false)->isCleared())->toBeTrue(); @@ -770,7 +771,7 @@ function capturesSent(): int $response = returnFromPaypal($paypalToken)->assertRedirect('http://shop.test/?shop_payment=paid'); $order = shopDb()->table('client')->where('session_id', 'checkout')->first(); - expect($order)->paypal->toEqual(1)->price_total->toEqual(105); + expect($order)->paypal->toEqual(1)->status->toBe('paid')->price_total->toEqual(105); Storage::disk('local')->assertExists("shop/paypal/{$paypalToken}.json"); expect(Storage::disk('local')->json("shop/paypal/{$paypalToken}.json"))->order_id->toBe($order->order_id); expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) @@ -1082,7 +1083,7 @@ function capturesSent(): int returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); actingAs(new User); - $orders = getJson('http://shop.test' . route('orders', [], false))->assertOk()->json('data')['']; + $orders = getJson('http://shop.test' . route('orders', [], false))->assertOk()->json('data')['']['orders']; expect($orders)->toHaveCount(1) ->and(array_column($orders[0]['orders'], 'name'))->toBe(['Chair']); @@ -1122,5 +1123,5 @@ function capturesSent(): int expect($schema->hasColumns('client', ['company', 'company_reg_no', 'legal_address']))->toBeTrue() ->and($schema->hasColumn('orders', 'weight'))->toBeTrue() - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(5); + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(6); }); diff --git a/editor/src/app/inputs/select-input.component.ts b/editor/src/app/inputs/select-input.component.ts index 2bdad3f46..34a91a73b 100644 --- a/editor/src/app/inputs/select-input.component.ts +++ b/editor/src/app/inputs/select-input.component.ts @@ -3,7 +3,8 @@ import { Input, Output, EventEmitter, - OnInit, + OnChanges, + SimpleChanges, ViewChild, ElementRef, } from '@angular/core'; @@ -64,11 +65,13 @@ import { SettingModel, SettingConfigModel } from '../shared/interfaces';
`, standalone: false, }) -export class SelectInputComponent implements OnInit { +export class SelectInputComponent implements OnChanges { @Input() label: string; @Input() tip?: string; @Input() value: SettingModel['value']; @Input() values: SettingConfigModel['values']; + /** Shown when the value matches none of the values */ + @Input() placeholder = '-'; @Output() update = new EventEmitter(); @Output() inputFocus = new EventEmitter(); @Input() enabledOnUpdate?: boolean; @@ -80,9 +83,12 @@ export class SelectInputComponent implements OnInit { private lastValue: SettingModel['value']; - ngOnInit() { - // Cache the value, so we don't update if nothing changes - this.lastValue = this.value; + ngOnChanges(changes: SimpleChanges) { + // Cache the value, so we don't update if nothing changes. A value set from outside, e.g. a + // filter cleared by a button, replaces it, so picking the earlier option again still updates. + if (changes['value']) { + this.lastValue = this.value; + } } toggleDropDown() { @@ -124,7 +130,7 @@ export class SelectInputComponent implements OnInit { }); if (!currentValue) { - return '-'; + return this.placeholder; } return currentValue.title; diff --git a/editor/src/app/shop/orders/shop-orders.actions.ts b/editor/src/app/shop/orders/shop-orders.actions.ts index c24f615f9..ac6c04e98 100644 --- a/editor/src/app/shop/orders/shop-orders.actions.ts +++ b/editor/src/app/shop/orders/shop-orders.actions.ts @@ -19,7 +19,18 @@ export class InitShopOrdersAction { export class LoadShopOrdersPageAction { static readonly type = 'SHOP_ORDERS:LOAD_PAGE'; - constructor(public page: number) {} + constructor( + public page: number, + public statusFilter: string | null = null, + ) {} +} + +export class UpdateShopOrderStatusAction { + static readonly type = 'SHOP_ORDERS:UPDATE_STATUS'; + constructor( + public id: number, + public status: string, + ) {} } /* Reset: */ diff --git a/editor/src/app/shop/orders/shop-orders.component.ts b/editor/src/app/shop/orders/shop-orders.component.ts index b501bb952..06bc57f0e 100644 --- a/editor/src/app/shop/orders/shop-orders.component.ts +++ b/editor/src/app/shop/orders/shop-orders.component.ts @@ -5,33 +5,83 @@ import { finalize } from 'rxjs/operators'; import { stringToCurrency } from '../../shared/helpers'; import { ShopSettingsState } from '../settings/shop-settings.state'; -import { LoadShopOrdersPageAction } from './shop-orders.actions'; +import { + LoadShopOrdersPageAction, + UpdateShopOrderStatusAction, +} from './shop-orders.actions'; import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; +/** The statuses of ShopOrderStatus in the shop plugin */ +const ORDER_STATUSES = [ + { value: 'awaiting_payment', title: 'Awaiting payment' }, + { value: 'paid', title: 'Paid' }, + { value: 'shipped', title: 'Shipped' }, + { value: 'cancelled', title: 'Cancelled' }, + { value: 'refunded', title: 'Refunded' }, +]; + @Component({ selector: 'berta-shop-orders', template: ` @if (ordersPage$ | async; as ordersPage) {
-

{{ ordersPage.total }} orders

- +
+
- - {{ isLoading ? 'Refreshing…' : 'Refresh' }} - + @for (chip of filterChips; track chip.title) { + + } +
+ @if (ordersPage.statusFilter && !ordersPage.orders.length) { +

No orders with this status.

+ } @for (order of ordersPage.orders; track order.id) {
-

- Order ID - {{ order.order_id }} -

+
+

+ Order ID {{ order.order_id }} +

+ +

Created {{ order.created_at }}

@@ -115,11 +165,12 @@ import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; >{{ currency$ | async }} {{ stringToCurrency(order.price_total) }} - @if (order.paypal) { - (PayPal) - }

+

+ Payment + {{ order.paypal ? 'PayPal' : 'Bank transfer' }} +

@if (order.orders && order.orders.length) { @@ -146,7 +197,7 @@ import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; type="button" class="button" [disabled]="isLoading || ordersPage.page <= 1" - (click)="goToPage(ordersPage.page - 1)" + (click)="goToPage(ordersPage.page - 1, ordersPage.statusFilter)" > ‹ Prev @@ -155,7 +206,7 @@ import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; type="button" class="button" [disabled]="isLoading || ordersPage.page >= ordersPage.lastPage" - (click)="goToPage(ordersPage.page + 1)" + (click)="goToPage(ordersPage.page + 1, ordersPage.statusFilter)" > Next › @@ -170,6 +221,18 @@ export class ShopOrdersComponent { currency$: Observable; isLoading = false; + // 'All' clears the filter; 'none' finds the orders placed before statuses existed + readonly filterChips: { value: string | null; title: string }[] = [ + { value: null, title: 'All' }, + { value: 'none', title: 'No status' }, + ...ORDER_STATUSES, + ]; + readonly statusOptions = ORDER_STATUSES; + // A PayPal order is saved only once PayPal has taken the money + readonly paypalStatusOptions = ORDER_STATUSES.filter( + (status) => status.value !== 'awaiting_payment', + ); + constructor( private store: Store, private elementRef: ElementRef, @@ -180,16 +243,20 @@ export class ShopOrdersComponent { this.currency$ = this.store.select(ShopSettingsState.getCurrentCurrency); } - goToPage(page: number) { + goToPage(page: number, statusFilter: string | null) { this.isLoading = true; this.store - .dispatch(new LoadShopOrdersPageAction(page)) + .dispatch(new LoadShopOrdersPageAction(page, statusFilter)) .pipe(finalize(() => (this.isLoading = false))) .subscribe(() => { this.elementRef.nativeElement.scrollIntoView({ block: 'start' }); }); } + updateStatus(orderId: number, status: string) { + this.store.dispatch(new UpdateShopOrderStatusAction(orderId, status)); + } + stringToCurrency(price) { return stringToCurrency(price); } diff --git a/editor/src/app/shop/orders/shop-orders.state.spec.ts b/editor/src/app/shop/orders/shop-orders.state.spec.ts index d42d457ac..26f7765c2 100644 --- a/editor/src/app/shop/orders/shop-orders.state.spec.ts +++ b/editor/src/app/shop/orders/shop-orders.state.spec.ts @@ -7,17 +7,21 @@ import { ShopOrdersPage, ShopOrdersState, } from './shop-orders.state'; -import { LoadShopOrdersPageAction } from './shop-orders.actions'; +import { + LoadShopOrdersPageAction, + UpdateShopOrderStatusAction, +} from './shop-orders.actions'; import { AppState } from '../../app-state/app.state'; import { AppStateService } from '../../app-state/app-state.service'; import { ShopStateService } from '../shop-state.service'; describe('ShopOrdersState', () => { const SECOND_PAGE: ShopOrdersPage = { - orders: [{ id: 5, order_id: 'ORD-5', orders: [] }], + orders: [{ id: 5, order_id: 'ORD-5', status: null, orders: [] }], page: 2, lastPage: 2, total: 21, + statusFilter: null, }; let sync: jasmine.Spy; @@ -57,4 +61,49 @@ describe('ShopOrdersState', () => { ); expect(patchState).toHaveBeenCalledWith({ blog: SECOND_PAGE }); }); + + it('requests the orders of the filtered status', () => { + const context = { patchState } as unknown as StateContext; + + state + .loadOrdersPage(context, new LoadShopOrdersPageAction(1, 'paid')) + .subscribe(); + + expect(sync).toHaveBeenCalledWith( + '/orders', + { site: 'blog', page: 1, status: 'paid' }, + 'GET', + ); + }); + + it('saves the status of an order and shows it on that order only', () => { + sync.and.returnValue(of({ message: '', data: { value: 'shipped' } })); + const orders = [ + { id: 6, order_id: 'ORD-6', status: 'paid' }, + { id: 5, order_id: 'ORD-5', status: 'paid' }, + ]; + const context = { + getState: () => ({ blog: { ...SECOND_PAGE, orders } }), + patchState, + } as unknown as StateContext; + + state + .updateOrderStatus(context, new UpdateShopOrderStatusAction(5, 'shipped')) + .subscribe(); + + expect(sync).toHaveBeenCalledWith( + '/orders', + { path: 'blog/5/status', value: 'shipped' }, + 'PATCH', + ); + expect(patchState).toHaveBeenCalledWith({ + blog: { + ...SECOND_PAGE, + orders: [ + { id: 6, order_id: 'ORD-6', status: 'paid' }, + { id: 5, order_id: 'ORD-5', status: 'shipped' }, + ], + }, + }); + }); }); diff --git a/editor/src/app/shop/orders/shop-orders.state.ts b/editor/src/app/shop/orders/shop-orders.state.ts index 0e9c7833a..ad9efaa7f 100644 --- a/editor/src/app/shop/orders/shop-orders.state.ts +++ b/editor/src/app/shop/orders/shop-orders.state.ts @@ -28,6 +28,7 @@ import { ResetShopOrdersAction, InitShopOrdersAction, LoadShopOrdersPageAction, + UpdateShopOrderStatusAction, } from './shop-orders.actions'; import { HttpErrorResponse } from '@angular/common/http'; import { DestroyRef, inject, Injectable } from '@angular/core'; @@ -40,6 +41,8 @@ export interface ShopOrdersPage { page: number; lastPage: number; total: number; + /** The status the orders are filtered by, null for all orders */ + statusFilter: string | null; } export interface ShopOrdersModel { @@ -121,7 +124,15 @@ export class ShopOrdersState implements NgxsOnInit { const syncURLs = this.store$.selectSnapshot(ShopState.getURLs); return this.appStateService - .sync(syncURLs.orders, { site, page: action.page }, 'GET') + .sync( + syncURLs.orders, + { + site, + page: action.page, + ...(action.statusFilter ? { status: action.statusFilter } : {}), + }, + 'GET', + ) .pipe( tap((response: { message: string; data: ShopOrdersModel }) => { patchState({ [site]: response.data[site] }); @@ -137,6 +148,50 @@ export class ShopOrdersState implements NgxsOnInit { ); } + /** + * An order whose new status doesn't match the filter stays listed until the next load, + * so the list doesn't jump while the shop owner works through it + */ + @Action(UpdateShopOrderStatusAction) + updateOrderStatus( + { getState, patchState }: StateContext, + action: UpdateShopOrderStatusAction, + ) { + const site = this.store$.selectSnapshot(AppState.getSite); + const syncURLs = this.store$.selectSnapshot(ShopState.getURLs); + + return this.appStateService + .sync( + syncURLs.orders, + { path: `${site}/${action.id}/status`, value: action.status }, + 'PATCH', + ) + .pipe( + tap((response: { message: string; data: { value: string } }) => { + const ordersPage = getState()[site]; + + patchState({ + [site]: { + ...ordersPage, + orders: ordersPage.orders.map((order) => + order.id === action.id + ? { ...order, status: response.data.value } + : order, + ), + }, + }); + }), + catchError((error: HttpErrorResponse | Error) => { + if (error instanceof HttpErrorResponse) { + console.error(error.error.message); + } else { + console.error(error.message); + } + throw error; + }), + ); + } + @Action(RenameShopOrdersSiteAction) renameOrdersSite( { setState, getState }: StateContext, diff --git a/editor/src/styles/_shop.scss b/editor/src/styles/_shop.scss index 5e8d7015b..2f78dc521 100644 --- a/editor/src/styles/_shop.scss +++ b/editor/src/styles/_shop.scss @@ -1,3 +1,4 @@ +@use "sass:color"; @use "variables"; berta-shop { @@ -220,6 +221,7 @@ berta-shop-orders { align-items: center; border-bottom: 1px solid variables.$grey-10; display: flex; + flex-wrap: wrap; justify-content: space-between; margin: 1.3em; padding-bottom: 1em; @@ -229,7 +231,51 @@ berta-shop-orders { margin: 0; } - button { + .bt-sh-orders-actions { + display: flex; + gap: 1.5em; + } + + // The status filter: chips on a row of its own, unlike the dropdown badge of an order's status + .bt-sh-orders-filter { + display: flex; + flex-basis: 100%; + flex-wrap: wrap; + gap: 0.5em; + margin-top: 1em; + + button { + background-color: variables.$white; + border: 1px solid variables.$grey-20; + border-radius: 1em; + color: variables.$grey-80; + cursor: pointer; + font-family: inherit; + font-size: variables.$input-font-size; + padding: 0.3em 0.8em; + transition: + background-color 0.2s, + border-color 0.2s, + color 0.2s; + + &:hover { + border-color: variables.$berta-accent; + color: variables.$berta-accent; + } + + &.bt-active { + background-color: variables.$berta-accent; + border-color: variables.$berta-accent; + color: variables.$white; + } + + &[disabled] { + cursor: default; + } + } + } + + .bt-sh-orders-actions button { align-items: center; background: none; color: variables.$black; @@ -262,6 +308,113 @@ berta-shop-orders { } } + .bt-sh-order-header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5em 1em; + justify-content: space-between; + + p { + display: block; + margin: 0; + } + } + + // An order's status: a dropdown drawn as a badge in the status's colour + berta-select-input.bt-sh-order-status { + --bt-status-color: #{variables.$grey-40}; + // Stays on the right when the header wraps, so its list still opens leftwards + margin-left: auto; + + &[data-status="awaiting_payment"] { + --bt-status-color: #{color.adjust( + variables.$warn-color, + $lightness: -15% + )}; + } + + &[data-status="paid"] { + --bt-status-color: #{color.adjust( + variables.$success-color, + $lightness: -8% + )}; + } + + &[data-status="shipped"] { + --bt-status-color: #{variables.$berta-accent}; + } + + &[data-status="cancelled"], + &[data-status="refunded"] { + --bt-status-color: #{variables.$grey-80}; + } + + .form-group { + margin: 0; + + .label-text { + display: none; + } + + .select-wrapper { + background-color: color-mix( + in srgb, + var(--bt-status-color) 10%, + #{variables.$white} + ); + border: 1px solid var(--bt-status-color); + border-radius: 1em; + color: var(--bt-status-color); + margin-left: 0; + min-width: 0; + width: auto; + + .button-wrapper button, + .button-wrapper button:focus { + color: inherit; + padding: 0.35em 0.5em 0.35em 0.9em; + } + + .drop-icon path { + stroke: currentColor; + } + + // Opens leftwards from the badge's right edge: wider than a short badge, a list + // growing rightwards would be cut off by the sidebar + ul { + border-radius: variables.$border-radius; + left: auto; + margin-top: 0.25em; + min-width: 11em; + right: -1px; + } + } + + &:hover .select-wrapper { + background-color: color-mix( + in srgb, + var(--bt-status-color) 18%, + #{variables.$white} + ); + } + } + + &[data-status="none"] .form-group .select-wrapper { + background-color: variables.$white; + border-style: dashed; + } + } + + .bt-sh-orders-empty { + margin: 1.3em; + + p { + color: variables.$grey-40; + margin: 0; + } + } + .bt-sh-orders-pager { align-items: center; display: flex; From c30b5834a211837b165f791025fe431fe7aabe77 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 13 Oct 2026 13:48:13 +0300 Subject: [PATCH 08/18] Order private notes for shop owner --- _api_app/tests/Feature/ShopControllerTest.php | 49 +++++++- .../Feature/ShopStorefrontControllerTest.php | 4 +- .../app/inputs/long-text-input.component.ts | 26 +++- .../app/shop/orders/shop-orders.actions.ts | 8 ++ .../shop/orders/shop-orders.component.spec.ts | 115 ++++++++++++++++++ .../app/shop/orders/shop-orders.component.ts | 44 ++++++- .../app/shop/orders/shop-orders.state.spec.ts | 37 ++++++ .../src/app/shop/orders/shop-orders.state.ts | 33 +++-- .../app/sites/shared/icon-edit.component.ts | 22 ++++ .../app/sites/shared/sites-shared.module.ts | 3 + editor/src/styles/_shop.scss | 57 +++++---- 11 files changed, 354 insertions(+), 44 deletions(-) create mode 100644 editor/src/app/shop/orders/shop-orders.component.spec.ts create mode 100644 editor/src/app/sites/shared/icon-edit.component.ts diff --git a/_api_app/tests/Feature/ShopControllerTest.php b/_api_app/tests/Feature/ShopControllerTest.php index c84b5aeff..b167ead11 100644 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ b/_api_app/tests/Feature/ShopControllerTest.php @@ -201,7 +201,7 @@ $regions = new ShopShippingRegionsDataService(''); expect($regions->query()->orderBy('id')->pluck('name')->all())->toBe(['Rock & "Roll"', 'Plain']) - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(6); + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(7); }); }); @@ -254,6 +254,14 @@ function shopOrderStatuses(): array return (new ShopClientsDataService(''))->query()->orderBy('id')->pluck('status', 'id')->all(); } +/** + * @return array + */ +function shopOrderNotes(): array +{ + return (new ShopClientsDataService(''))->query()->orderBy('id')->pluck('admin_notes', 'id')->all(); +} + describe('orders', function () { beforeEach(function () { $this->bertaRoot = setUpShopTestRoot(); @@ -367,6 +375,7 @@ function shopOrderStatuses(): array 'a basket' => ['/2/status', 'Order not found!'], 'a missing order' => ['/9/status', 'Order not found!'], 'an unknown site' => ['nosite/1/status', 'Site not found!'], + 'the notes of a basket' => ['/2/admin_notes', 'Order not found!'], ]); it('returns 422 for a status change of another field or to an unknown status', function () { @@ -379,7 +388,40 @@ function shopOrderStatuses(): array expect(shopOrderStatuses())->toBe([1 => null, 2 => null]); }); - it('adds an empty status to the orders of an older shop version', function () { + it('saves the private notes of an order', function () { + placeShopOrders(2); + $notes = "Called the customer.\nShips on Friday."; + + patchJson('http://shop.test' . route('orders', [], false), ['path' => '/2/admin_notes', 'value' => $notes]) + ->assertOk() + ->assertJsonPath('data', ['site' => '', 'path' => '2/admin_notes', 'value' => $notes]); + + expect(shopOrderNotes())->toBe([1 => null, 2 => $notes, 3 => null]) + ->and(getShopOrdersPage(1)['orders'][0])->toMatchArray(['order_id' => 'ORD-2', 'admin_notes' => $notes]); + }); + + it('clears the private notes of an order', function () { + placeShopOrders(1); + updateShopOrders([1 => ['admin_notes' => 'Ships on Friday.']]); + + patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/admin_notes', 'value' => '']) + ->assertOk() + ->assertJsonPath('data.value', null); + + expect(shopOrderNotes())->toBe([1 => null, 2 => null]); + }); + + it('returns 422 for private notes over 5000 characters', function () { + placeShopOrders(1); + + patchJson('http://shop.test' . route('orders', [], false), ['path' => '/1/admin_notes', 'value' => str_repeat('a', 5001)]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['value']); + + expect(shopOrderNotes())->toBe([1 => null, 2 => null]); + }); + + it('adds an empty status and private notes to the orders of an older shop version', function () { writeShopSettings($this->bertaRoot, ['version' => '5']); $database = new SQLite3($this->bertaRoot . '/storage/shop-db.sqlite'); $database->exec('CREATE TABLE "client" ("id" integer not null, "created_at" datetime not null, "session_id" varchar not null, "order_id" varchar, primary key ("id"))'); @@ -390,6 +432,7 @@ function shopOrderStatuses(): array expect($page['orders'])->toHaveCount(1) ->and($page['orders'][0])->toHaveKey('status', null) - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(6); + ->and($page['orders'][0])->toHaveKey('admin_notes', null) + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(7); }); }); diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php index fb34e3fa7..1c8d566ee 100644 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -1121,7 +1121,7 @@ function capturesSent(): int $schema = (new ShopClientsDataService(''))->getConnection()->getSchemaBuilder(); - expect($schema->hasColumns('client', ['company', 'company_reg_no', 'legal_address']))->toBeTrue() + expect($schema->hasColumns('client', ['company', 'company_reg_no', 'legal_address', 'status', 'admin_notes']))->toBeTrue() ->and($schema->hasColumn('orders', 'weight'))->toBeTrue() - ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(6); + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(7); }); diff --git a/editor/src/app/inputs/long-text-input.component.ts b/editor/src/app/inputs/long-text-input.component.ts index b25424852..02d7c2e8d 100644 --- a/editor/src/app/inputs/long-text-input.component.ts +++ b/editor/src/app/inputs/long-text-input.component.ts @@ -1,5 +1,14 @@ import { tap } from 'rxjs/operators'; -import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { + afterNextRender, + Component, + ElementRef, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core'; import { TextInputService } from './text-input.service'; @Component({ @@ -12,12 +21,12 @@ import { TextInputService } from './text-input.service'; `, styles: [ @@ -40,11 +49,20 @@ export class LongTextInputComponent implements OnInit { @Input() disabled?: boolean; @Input() disabledReason?: string; @Input() enabledOnUpdate?: boolean; + /** Focuses the textarea once it's shown */ + @Input() autofocus?: boolean; @Input() value: string; @Output() update = new EventEmitter(); @Output() inputFocus = new EventEmitter(); + @ViewChild('textarea') textarea: ElementRef; - constructor(public textInputService: TextInputService) {} + constructor(public textInputService: TextInputService) { + afterNextRender(() => { + if (this.autofocus) { + this.textarea.nativeElement.focus(); + } + }); + } ngOnInit() { this.textInputService.initValue(this.value, { isLongInput: true }); diff --git a/editor/src/app/shop/orders/shop-orders.actions.ts b/editor/src/app/shop/orders/shop-orders.actions.ts index ac6c04e98..44986b543 100644 --- a/editor/src/app/shop/orders/shop-orders.actions.ts +++ b/editor/src/app/shop/orders/shop-orders.actions.ts @@ -33,6 +33,14 @@ export class UpdateShopOrderStatusAction { ) {} } +export class UpdateShopOrderNotesAction { + static readonly type = 'SHOP_ORDERS:UPDATE_NOTES'; + constructor( + public id: number, + public notes: string, + ) {} +} + /* Reset: */ export class ResetShopOrdersAction { public static type = 'SHOP_ORDERS:RESET'; diff --git a/editor/src/app/shop/orders/shop-orders.component.spec.ts b/editor/src/app/shop/orders/shop-orders.component.spec.ts new file mode 100644 index 000000000..96a774ddc --- /dev/null +++ b/editor/src/app/shop/orders/shop-orders.component.spec.ts @@ -0,0 +1,115 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; +import { of } from 'rxjs'; + +import { SitesSharedModule } from '../../sites/shared/sites-shared.module'; +import { ShopSettingsState } from '../settings/shop-settings.state'; +import { UpdateShopOrderNotesAction } from './shop-orders.actions'; +import { ShopOrdersComponent } from './shop-orders.component'; +import { ShopOrdersPage } from './shop-orders.state'; + +describe('ShopOrdersComponent', () => { + const ORDERS_PAGE: ShopOrdersPage = { + orders: [ + { id: 6, order_id: 'ORD-6', admin_notes: 'Ships on Friday.', orders: [] }, + { id: 5, order_id: 'ORD-5', admin_notes: null, orders: [] }, + ], + page: 1, + lastPage: 1, + total: 2, + statusFilter: null, + }; + + let fixture: ComponentFixture; + let dispatch: jasmine.Spy; + + const orderCards = () => + Array.from(fixture.nativeElement.querySelectorAll('.setting')); + const addNotesButton = (card: HTMLElement) => + card.querySelector('.bt-sh-order-add-notes'); + const notesTextarea = (card: HTMLElement) => card.querySelector('textarea'); + + /** The events a browser fires when the textarea loses focus */ + const leave = (textarea: HTMLTextAreaElement) => { + textarea.dispatchEvent(new FocusEvent('blur')); + textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + }; + + const openNotes = async (card: HTMLElement) => { + addNotesButton(card).click(); + fixture.detectChanges(); + await fixture.whenStable(); + + return notesTextarea(card); + }; + + beforeEach(async () => { + dispatch = jasmine.createSpy('dispatch').and.returnValue(of(undefined)); + const store = { + select: (selector: unknown) => + of( + selector === ShopSettingsState.getCurrentCurrency + ? 'EUR' + : ORDERS_PAGE, + ), + dispatch, + } as unknown as Store; + + await TestBed.configureTestingModule({ + imports: [SitesSharedModule], + declarations: [ShopOrdersComponent], + providers: [{ provide: Store, useValue: store }], + }).compileComponents(); + + fixture = TestBed.createComponent(ShopOrdersComponent); + document.body.appendChild(fixture.nativeElement); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.nativeElement.remove(); + }); + + it('shows the notes of an order that has them, and a button on one without', () => { + const [withNotes, withoutNotes] = orderCards(); + + expect(notesTextarea(withNotes).value).toBe('Ships on Friday.'); + expect(addNotesButton(withNotes)).toBeNull(); + expect(notesTextarea(withoutNotes)).toBeNull(); + expect(addNotesButton(withoutNotes).textContent.trim()).toBe( + 'Add private notes', + ); + }); + + it('opens a focused notes field from the button', async () => { + const textarea = await openNotes(orderCards()[1]); + + expect(textarea).not.toBeNull(); + expect(document.activeElement).toBe(textarea); + }); + + it('closes an opened notes field left empty, without saving', async () => { + const card = orderCards()[1]; + const textarea = await openNotes(card); + + leave(textarea); + + expect(notesTextarea(card)).toBeNull(); + expect(addNotesButton(card)).not.toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('saves typed notes and keeps the notes field open', async () => { + const card = orderCards()[1]; + const textarea = await openNotes(card); + + textarea.value = 'Gift wrap'; + leave(textarea); + + expect(dispatch).toHaveBeenCalledWith( + new UpdateShopOrderNotesAction(5, 'Gift wrap'), + ); + expect(notesTextarea(card)).toBe(textarea); + }); +}); diff --git a/editor/src/app/shop/orders/shop-orders.component.ts b/editor/src/app/shop/orders/shop-orders.component.ts index 06bc57f0e..9c9edef2c 100644 --- a/editor/src/app/shop/orders/shop-orders.component.ts +++ b/editor/src/app/shop/orders/shop-orders.component.ts @@ -7,6 +7,7 @@ import { stringToCurrency } from '../../shared/helpers'; import { ShopSettingsState } from '../settings/shop-settings.state'; import { LoadShopOrdersPageAction, + UpdateShopOrderNotesAction, UpdateShopOrderStatusAction, } from './shop-orders.actions'; import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; @@ -112,6 +113,12 @@ const ORDER_STATUSES = [

Email {{ order.email }}

+ @if (order.notes) { +

+ Customer notes + {{ order.notes }} +

+ } @if ( order.ship_name || order.ship_phone || @@ -135,7 +142,7 @@ const ORDER_STATUSES = [ {{ order.ship_email }}
} @if (order.ship_notes) { - {{ order.ship_notes }}
+ {{ order.ship_notes }} }

@@ -189,6 +196,25 @@ const ORDER_STATUSES = [
}
+ @if (order.admin_notes || notesOpenedFor.has(order.id)) { + + } @else { + + }
} @if (ordersPage.lastPage > 1) { @@ -220,6 +246,8 @@ export class ShopOrdersComponent { ordersPage$: Observable; currency$: Observable; isLoading = false; + /** The orders without notes whose notes field the shop owner has opened */ + notesOpenedFor = new Set(); // 'All' clears the filter; 'none' finds the orders placed before statuses existed readonly filterChips: { value: string | null; title: string }[] = [ @@ -257,6 +285,20 @@ export class ShopOrdersComponent { this.store.dispatch(new UpdateShopOrderStatusAction(orderId, status)); } + updateNotes(orderId: number, notes: string) { + this.store.dispatch(new UpdateShopOrderNotesAction(orderId, notes)); + } + + /** + * Collapses an empty notes field back to its button. Runs after the textarea's blur has saved, + * and reads the textarea rather than the store, so typed notes stay open while they're saved. + */ + closeEmptyNotes(orderId: number, event: FocusEvent) { + if (!(event.target as HTMLTextAreaElement).value.trim()) { + this.notesOpenedFor.delete(orderId); + } + } + stringToCurrency(price) { return stringToCurrency(price); } diff --git a/editor/src/app/shop/orders/shop-orders.state.spec.ts b/editor/src/app/shop/orders/shop-orders.state.spec.ts index 26f7765c2..3b28cab49 100644 --- a/editor/src/app/shop/orders/shop-orders.state.spec.ts +++ b/editor/src/app/shop/orders/shop-orders.state.spec.ts @@ -9,6 +9,7 @@ import { } from './shop-orders.state'; import { LoadShopOrdersPageAction, + UpdateShopOrderNotesAction, UpdateShopOrderStatusAction, } from './shop-orders.actions'; import { AppState } from '../../app-state/app.state'; @@ -106,4 +107,40 @@ describe('ShopOrdersState', () => { }, }); }); + + it('saves the private notes of an order and shows them on that order only', () => { + sync.and.returnValue( + of({ message: '', data: { value: 'Ships on Friday.' } }), + ); + const orders = [ + { id: 6, order_id: 'ORD-6', admin_notes: null }, + { id: 5, order_id: 'ORD-5', admin_notes: null }, + ]; + const context = { + getState: () => ({ blog: { ...SECOND_PAGE, orders } }), + patchState, + } as unknown as StateContext; + + state + .updateOrderNotes( + context, + new UpdateShopOrderNotesAction(5, 'Ships on Friday.'), + ) + .subscribe(); + + expect(sync).toHaveBeenCalledWith( + '/orders', + { path: 'blog/5/admin_notes', value: 'Ships on Friday.' }, + 'PATCH', + ); + expect(patchState).toHaveBeenCalledWith({ + blog: { + ...SECOND_PAGE, + orders: [ + { id: 6, order_id: 'ORD-6', admin_notes: null }, + { id: 5, order_id: 'ORD-5', admin_notes: 'Ships on Friday.' }, + ], + }, + }); + }); }); diff --git a/editor/src/app/shop/orders/shop-orders.state.ts b/editor/src/app/shop/orders/shop-orders.state.ts index ad9efaa7f..52c2dff22 100644 --- a/editor/src/app/shop/orders/shop-orders.state.ts +++ b/editor/src/app/shop/orders/shop-orders.state.ts @@ -29,6 +29,7 @@ import { InitShopOrdersAction, LoadShopOrdersPageAction, UpdateShopOrderStatusAction, + UpdateShopOrderNotesAction, } from './shop-orders.actions'; import { HttpErrorResponse } from '@angular/common/http'; import { DestroyRef, inject, Injectable } from '@angular/core'; @@ -154,28 +155,42 @@ export class ShopOrdersState implements NgxsOnInit { */ @Action(UpdateShopOrderStatusAction) updateOrderStatus( - { getState, patchState }: StateContext, + context: StateContext, action: UpdateShopOrderStatusAction, + ) { + return this.saveOrderField(context, action.id, 'status', action.status); + } + + @Action(UpdateShopOrderNotesAction) + updateOrderNotes( + context: StateContext, + action: UpdateShopOrderNotesAction, + ) { + return this.saveOrderField(context, action.id, 'admin_notes', action.notes); + } + + /** Saves one field of an order and shows the saved value on that order */ + private saveOrderField( + { getState, patchState }: StateContext, + id: number, + field: 'status' | 'admin_notes', + value: string, ) { const site = this.store$.selectSnapshot(AppState.getSite); const syncURLs = this.store$.selectSnapshot(ShopState.getURLs); return this.appStateService - .sync( - syncURLs.orders, - { path: `${site}/${action.id}/status`, value: action.status }, - 'PATCH', - ) + .sync(syncURLs.orders, { path: `${site}/${id}/${field}`, value }, 'PATCH') .pipe( - tap((response: { message: string; data: { value: string } }) => { + tap((response: { message: string; data: { value: string | null } }) => { const ordersPage = getState()[site]; patchState({ [site]: { ...ordersPage, orders: ordersPage.orders.map((order) => - order.id === action.id - ? { ...order, status: response.data.value } + order.id === id + ? { ...order, [field]: response.data.value } : order, ), }, diff --git a/editor/src/app/sites/shared/icon-edit.component.ts b/editor/src/app/sites/shared/icon-edit.component.ts new file mode 100644 index 000000000..6112065b9 --- /dev/null +++ b/editor/src/app/sites/shared/icon-edit.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'bt-icon-edit', + template: ` + + + + `, + standalone: false, +}) +export class IconEditComponent {} diff --git a/editor/src/app/sites/shared/sites-shared.module.ts b/editor/src/app/sites/shared/sites-shared.module.ts index 2eb3933cc..f50fcf98c 100644 --- a/editor/src/app/sites/shared/sites-shared.module.ts +++ b/editor/src/app/sites/shared/sites-shared.module.ts @@ -29,6 +29,7 @@ import { IconMoveComponent } from './icon-move.component'; import { IconCropComponent } from './icon-crop.component'; import { IconSwapComponent } from './icon-switch.component'; import { IconRefreshComponent } from './icon-refresh.component'; +import { IconEditComponent } from './icon-edit.component'; @NgModule({ imports: [ @@ -64,6 +65,7 @@ import { IconRefreshComponent } from './icon-refresh.component'; IconMoveComponent, IconSwapComponent, IconRefreshComponent, + IconEditComponent, HelpTooltipComponent, ], exports: [ @@ -91,6 +93,7 @@ import { IconRefreshComponent } from './icon-refresh.component'; IconMoveComponent, IconSwapComponent, IconRefreshComponent, + IconEditComponent, HelpTooltipComponent, ], }) diff --git a/editor/src/styles/_shop.scss b/editor/src/styles/_shop.scss index 2f78dc521..e1fb3d437 100644 --- a/editor/src/styles/_shop.scss +++ b/editor/src/styles/_shop.scss @@ -274,37 +274,39 @@ berta-shop-orders { } } } + } - .bt-sh-orders-actions button { - align-items: center; - background: none; - color: variables.$black; - cursor: pointer; - display: flex; - font-family: inherit; - font-size: variables.$input-font-size; - gap: 0.4em; - padding: 0; - transition: color 0.2s; + // A text button with an icon: Refresh, and an order's "Add private notes" + .bt-sh-orders-actions button, + .bt-sh-order-add-notes { + align-items: center; + background: none; + color: variables.$black; + cursor: pointer; + display: flex; + font-family: inherit; + font-size: variables.$input-font-size; + gap: 0.4em; + padding: 0; + transition: color 0.2s; - svg { - display: block; - height: 1.15em; - width: 1.15em; + svg { + display: block; + height: 1.15em; + width: 1.15em; - path { - fill: currentColor; - } + path { + fill: currentColor; } + } - &:hover { - color: variables.$berta-accent; - } + &:hover { + color: variables.$berta-accent; + } - &[disabled] { - color: variables.$grey-40; - cursor: default; - } + &[disabled] { + color: variables.$grey-40; + cursor: default; } } @@ -406,6 +408,11 @@ berta-shop-orders { } } + // Keeps the line breaks of notes typed over several lines + .bt-sh-order-notes { + white-space: pre-line; + } + .bt-sh-orders-empty { margin: 1.3em; From eabe38bc7708ee30bd4773aac69d9ce653608542 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 14 Oct 2026 08:31:39 +0300 Subject: [PATCH 09/18] Updated payment flow messages, currency after sum everywhere, shop email smtp configuration --- _api_app/.env.example | 5 + _api_app/app/Shop/shoppingCart.twig | 9 +- ...dataset__in_the_editor__by_default__.snap" | 9 +- ...dataset__in_the_editor__customised__.snap" | 9 +- ...__dataset__on_the_site__by_default__.snap" | 9 +- ...__dataset__on_the_site__customised__.snap" | 9 +- .../Feature/ShopStorefrontControllerTest.php | 154 +++++++++++++++++- editor/src/app/render/twig-templates.ts | 9 +- engine/lang/lv.php | 4 +- engine/lang/pl.php | 4 +- 10 files changed, 189 insertions(+), 32 deletions(-) diff --git a/_api_app/.env.example b/_api_app/.env.example index e4a6b81d8..b619ad5a9 100644 --- a/_api_app/.env.example +++ b/_api_app/.env.example @@ -10,3 +10,8 @@ AI_DEFAULT_PROVIDER=anthropic ANTHROPIC_API_KEY= AI_FEEDBACK_SCRIPT_URL= AI_FEEDBACK_SCRIPT_SECRET= +SHOP_MAIL_HOST= +SHOP_MAIL_PORT= +SHOP_MAIL_USERNAME= +SHOP_MAIL_PASSWORD= +SHOP_MAIL_AUTO_TLS= diff --git a/_api_app/app/Shop/shoppingCart.twig b/_api_app/app/Shop/shoppingCart.twig index 5f4906f77..5eecfe842 100644 --- a/_api_app/app/Shop/shoppingCart.twig +++ b/_api_app/app/Shop/shoppingCart.twig @@ -15,6 +15,7 @@ @@ -124,7 +125,7 @@ {{ shippingTotal.content }} -
{{ currency }}
+
{{ currency }}
@@ -160,8 +161,8 @@ {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %}
{% endif %} -
{{ currency }}
- +
{{ currency }}
+
@@ -463,7 +464,7 @@

{% endif %} -

{{ totalLabel }} {{ currency }}

+

{{ totalLabel }} {{ currency }}

{% if isEditMode %} {% if config.termsLink %} diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" index 921c19bb7..06b868725 100644 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__by_default__.snap" @@ -15,6 +15,7 @@ @@ -90,7 +91,7 @@ Shipping -
EUR
+
EUR
@@ -113,8 +114,8 @@
 incl vat 
-
EUR
- +
EUR
+ @@ -277,7 +278,7 @@

 checkout description 

-

total EUR

+

total EUR

diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" index fe3c2b7c8..e33225f8a 100644 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__in_the_editor__customised__.snap" @@ -15,6 +15,7 @@ @@ -90,7 +91,7 @@ Custom shippingTotalText -
EUR
+
EUR
@@ -113,8 +114,8 @@
Custom cartTextInclVat
-
EUR
- +
EUR
+ @@ -277,7 +278,7 @@

Custom cartTextCheckoutDescription

-

Custom cartTextTotal EUR

+

Custom cartTextTotal EUR

diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" index b0f9f03fd..25466871e 100644 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__by_default__.snap" @@ -15,6 +15,7 @@ @@ -84,7 +85,7 @@ Shipping -
EUR
+
EUR
@@ -103,8 +104,8 @@
total -
EUR
- +
EUR
+
@@ -250,7 +251,7 @@
-

total EUR

+

total EUR

Please fill in all required fields

diff --git "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" index 2e956a839..c30ae44d3 100644 --- "a/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" +++ "b/_api_app/tests/.pest/snapshots/Feature/ShopCartRenderServiceTest/_editable_texts__\342\206\222_it_renders_the_cart_texts_with_data_set__dataset__on_the_site__customised__.snap" @@ -15,6 +15,7 @@ @@ -84,7 +85,7 @@ Custom shippingTotalText -
EUR
+
EUR
@@ -103,8 +104,8 @@
Custom cartTextTotal -
EUR
- +
EUR
+
@@ -257,7 +258,7 @@

Custom cartTextCheckoutDescription

-

Custom cartTextTotal EUR

+

Custom cartTextTotal EUR

Custom cartTextFillRequiredFields

diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php index 1c8d566ee..44da83880 100644 --- a/_api_app/tests/Feature/ShopStorefrontControllerTest.php +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -144,6 +144,34 @@ function shopCheckout(string $token, array $form = [], string $site = ''): TestR ], $form)); } +/** + * The order email's text as its reader sees it, without the markup + */ +function shopMailText(ShopOrderMail $mail): string +{ + return preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($mail->render()), ENT_QUOTES)); +} + +/** + * Where the order email's html and text parts match SpamAssassin's HEXHASH_WORD pattern: a word, a hash-like run + * of digits and hyphens (as an order number is), then a word. Two matches in an email add spam points. + * + * @return list + */ +function shopMailHexHashWords(ShopOrderMail $mail): array +{ + // Spam filters read the html with whitespace at block tags, as a reader sees it + $html = html_entity_decode(strip_tags(preg_replace('/<(br|p|div|td|tr|h1|title)\b/i', ' $0', $mail->render())), ENT_QUOTES); + $text = view('Plugins/Shop/orderMailText', $mail->buildViewData())->render(); + preg_match_all( + '/\s[A-Z]?[a-z]{1,15}\s(?![a-z]{10,20}\s)[a-z]{0,10}(?!-?\d{1,5}-)(?!\d{10}\s)(?:(?!--)[-0-9a-f]){10,64}(?:[g-z][a-z]{0,10})?\s[A-Z]?[a-z]{1,15}\b/', + preg_replace('/\s+/', ' ', "{$html} {$text}"), + $matches, + ); + + return $matches[0]; +} + function fakeNvp(string $captureAck = 'Success'): void { Http::fake(['api-3t.paypal.com/nvp' => fn (HttpRequest $request) => match ($request['METHOD']) { @@ -509,12 +537,96 @@ function capturesSent(): int shopCheckout($token)->assertOk(); Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller - && str_contains($mail->render(), "{$thanks}")); + && str_contains(shopMailText($mail), $thanks)); })->with([ 'with a page title' => [['pageTitle' => 'Riga Prints'], 'Thanks for shopping with Riga Prints!'], 'without one' => [[], 'Thanks for shopping with us!'], ]); + it('asks the customer to pay to the seller details and tells the seller the order is not paid yet', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, ['sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]); + $token = fillShopBasket([[SHOP_CHAIR]]); + + $orderId = shopCheckout($token)->assertOk()->json('data.order_id'); + + // 100 + 5 shipping + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($orderId) { + $html = $mail->render(); + $text = shopMailText($mail); + + return ! $mail->forSeller + && $mail->hasSubject('Your order') + && str_contains($text, 'To complete it, please pay by bank transfer.') + && str_contains($html, "Transfer 105.00 EUR to the account below, with the payment reference {$orderId}, so we can match your payment.") + && str_contains($text, "Amount 105.00 EUR Reference {$orderId}") + && substr_count($html, 'IBAN LV80BANK0000435195001') === 1 + && str_contains($html, 'Riga Prints SIA
') + && str_contains($text, 'PAYMENT Bank transfer, awaiting payment') + && ! str_contains($html, 'being processed'); + }); + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($orderId) { + $html = $mail->render(); + + return $mail->forSeller + && $mail->hasSubject("New order {$orderId}: awaiting bank transfer") + && str_contains($html, 'No payment has been received yet.') + && str_contains($html, "Wait for 105.00 EUR to arrive in your bank account, with the payment reference {$orderId}.") + && str_contains($html, 'IBAN LV80BANK0000435195001') + && ! str_contains($html, 'did not get bank details'); + }); + }); + + it('tells the customer the bank details follow and the seller to send them when the shop has none', function () { + Mail::fake(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller + && str_contains($mail->render(), "We'll send you the bank details for the transfer shortly")); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller + && str_contains($mail->render(), 'the customer did not get bank details. Send them yours.')); + }); + + it('sends a plain text part with the same words as the html one', function () { + Mail::fake(); + writeShopSettings($this->bertaRoot, ['sellerDetails' => 'Riga Prints SIA, IBAN LV80BANK0000435195001']); + $token = fillShopBasket([[SHOP_CHAIR]]); + + $orderId = shopCheckout($token, ['name' => "Ann & O'Brien"])->assertOk()->json('data.order_id'); + + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($orderId) { + if ($mail->forSeller) { + $mail->assertSeeInText("Ann & O'Brien placed an order of 105.00 EUR and chose to pay by bank transfer.") + ->assertSeeInText('Total: 105.00 EUR'); + } else { + $mail->assertSeeInText("Transfer 105.00 EUR to the account below, with the payment reference {$orderId}, so we can match your payment.") + ->assertSeeInText('IBAN LV80BANK0000435195001'); + } + $mail->assertDontSeeInText(''); + + return true; + }); + }); + + it('words the order number so spam filters do not take it for a hash', function (array $shop) { + Mail::fake(); + writeShopSettings($this->bertaRoot, $shop); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) { + expect(shopMailHexHashWords($mail))->toBe([]); + + return true; + }); + })->with([ + 'with seller details' => [['sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]], + 'without seller details' => [[]], + ]); + it('takes off at most the price of the products for a promo discount over 100%', function () { writeShopSettings($this->bertaRoot, ['promoCodeDiscount' => '150']); $token = fillShopBasket([[SHOP_CHAIR]]); @@ -534,11 +646,11 @@ function capturesSent(): int // 100 + 5 shipping, of which 25.5% VAT expect(shopDb()->table('client')->where('session_id', 'checkout')->value('vat'))->toEqual(25.5); Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) { - $html = $mail->render(); + $text = shopMailText($mail); return $mail->totals['vat_percent'] === 25.5 - && str_contains($html, 'Total excl. VAT83.67') - && str_contains($html, 'VAT (25.5%)21.33'); + && str_contains($text, 'Total excl. VAT 83.67') + && str_contains($text, 'VAT (25.5%) 21.33'); }); }); @@ -780,6 +892,40 @@ function capturesSent(): int Mail::assertSent(ShopOrderMail::class, 2); })->with(['nvp', 'rest']); + it('tells the customer the order is paid and the seller to ship it', function (string $method, string $title, string $paidWith) { + Mail::fake(); + writeShopSettings($this->bertaRoot, restSettings()); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => $method])->assertOk(); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=paid'); + + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($title, $paidWith) { + $html = $mail->render(); + + return ! $mail->forSeller + && $mail->hasSubject('Your order') + && str_contains($html, "We've received your payment of 105.00 EUR {$paidWith} and confirmed your order {$mail->order['order_id']}.") + && str_contains(shopMailText($mail), "PAYMENT {$title}, paid") + && ! str_contains($html, 'bank transfer') + && shopMailHexHashWords($mail) === []; + }); + Mail::assertSent(ShopOrderMail::class, function (ShopOrderMail $mail) use ($paidWith) { + $html = $mail->render(); + + return $mail->forSeller + && $mail->hasSubject("New order {$mail->order['order_id']}: paid {$paidWith}") + && str_contains($html, "Ann Buyer paid 105.00 EUR {$paidWith}.") + && str_contains($html, 'ship the order to the address below') + && ! str_contains($html, '(paypal)') + && shopMailHexHashWords($mail) === []; + }); + })->with([ + 'PayPal' => ['paypal', 'PayPal', 'with PayPal'], + 'card' => ['creditcard', 'Card (via PayPal)', 'by card via PayPal'], + ]); + it('saves nothing when PayPal declines the payment', function (string $api) { Mail::fake(); writeShopSettings($this->bertaRoot, $api === 'nvp' ? nvpSettings() : restSettings()); diff --git a/editor/src/app/render/twig-templates.ts b/editor/src/app/render/twig-templates.ts index 71ea567b5..19cc5afb1 100644 --- a/editor/src/app/render/twig-templates.ts +++ b/editor/src/app/render/twig-templates.ts @@ -18,6 +18,7 @@ export const TWIG_TEMPLATES: Record = { @@ -127,7 +128,7 @@ export const TWIG_TEMPLATES: Record = { {{ shippingTotal.content }} -
{{ currency }}
+
{{ currency }}
@@ -163,8 +164,8 @@ export const TWIG_TEMPLATES: Record = { {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %} {% endif %} -
{{ currency }}
- +
{{ currency }}
+ @@ -466,7 +467,7 @@ export const TWIG_TEMPLATES: Record = {

{% endif %} -

{{ totalLabel }} {{ currency }}

+

{{ totalLabel }} {{ currency }}

{% if isEditMode %} {% if config.termsLink %} diff --git a/engine/lang/lv.php b/engine/lang/lv.php index 58b8117d2..df338a53f 100644 --- a/engine/lang/lv.php +++ b/engine/lang/lv.php @@ -501,9 +501,9 @@ 'Shop currency' => 'Veikala valūta', 'Currency abbreviation or symbol displayed by all prices in the site.' => 'Valūtas abreviatūra vai simbols, kurš redzams pie preces cenas.', 'Order Email Subject' => 'Tēmas nosaukums e-pastam', - 'Subject of order email' => 'Tēmas nosaukums e-pastam pie pasūtījuma veikšanas.', + 'Subject of the order email sent to the customer' => 'Klientam nosūtītā pasūtījuma e-pasta tēma.', 'Seller details' => 'Tirgotāja rekvizīti', - 'Seller details will be included in bill and e-mail message' => 'Tirgotāja rekvizīti tiks iekļauti rēķinā un e-pasta saturā.', + 'Your business and bank account details, included in order emails. Customers paying by bank transfer pay to the account given here.' => 'Jūsu uzņēmuma un bankas konta rekvizīti, kas tiek iekļauti pasūtījumu e-pastos. Klienti, kas maksā ar bankas pārskaitījumu, maksā uz šeit norādīto kontu.', 'Add to basket' => 'Pievienot grozam', 'Add to basket text' => 'Pogas "Pievienot grozam" nosaukums.', 'Out of stock' => 'Noliktavā nav', diff --git a/engine/lang/pl.php b/engine/lang/pl.php index 38a0e9c34..716841019 100644 --- a/engine/lang/pl.php +++ b/engine/lang/pl.php @@ -491,9 +491,9 @@ 'Shop currency' => 'Waluta sklepu', 'Currency abbreviation or symbol displayed by all prices in the site.' => 'Skrót waluty lub symbol, który będzie widoczny przy cenie towaru.', 'Order Email Subject' => 'Nazwa tematu w e-mailu', - 'Subject of order email' => 'Nazwa tematu dla e-mailu przy dokonywaniu zamówienia.', + 'Subject of the order email sent to the customer' => 'Temat e-maila z zamówieniem wysyłanego do klienta.', 'Seller details' => 'Dane sprzedawcy', - 'Seller details will be included in bill and e-mail message' => 'Dane sprzedawcy będą zawarte na fakturze oraz w treści e-mailu.', + 'Your business and bank account details, included in order emails. Customers paying by bank transfer pay to the account given here.' => 'Dane Twojej firmy i rachunku bankowego, zawarte w e-mailach z zamówieniem. Klienci płacący przelewem bankowym wpłacają na podany tu rachunek.', 'Add to basket' => 'Włożyć do koszyka', 'Add to basket text' => 'Nazwa przycisku "Włożyć do koszyka".', 'Out of stock' => 'Nie ma w magazynie', From 7e83d53bfeac1512b264cb120ef2ac77250ae7c7 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 14 Oct 2026 08:59:47 +0300 Subject: [PATCH 10/18] Updated payment flow messages and payment method labels --- _api_app/app/Shop/shoppingCart.twig | 6 ++--- ...dataset__in_the_editor__by_default__.snap" | 8 +++---- ...dataset__in_the_editor__customised__.snap" | 6 ++--- ...__dataset__on_the_site__by_default__.snap" | 2 +- _api_app/tests/Feature/ShopControllerTest.php | 9 +++---- .../Feature/ShopStorefrontControllerTest.php | 8 ++++--- editor/src/app/render/twig-templates.ts | 6 ++--- .../shop/orders/shop-orders.component.spec.ts | 24 +++++++++++++++++-- .../app/shop/orders/shop-orders.component.ts | 23 +++++++++++++++++- .../app/shop/shop-cart-render.service.spec.ts | 2 +- .../src/app/shop/shop-cart-render.service.ts | 4 ++-- 11 files changed, 71 insertions(+), 27 deletions(-) diff --git a/_api_app/app/Shop/shoppingCart.twig b/_api_app/app/Shop/shoppingCart.twig index 5eecfe842..e70a3b53b 100644 --- a/_api_app/app/Shop/shoppingCart.twig +++ b/_api_app/app/Shop/shoppingCart.twig @@ -434,11 +434,11 @@ {% if isPaypalAvailable %}
+ Debit or credit card
+ PayPal
{% endif %} @@ -447,7 +447,7 @@