diff --git a/.gitignore b/.gitignore index 4ada97ac8..103cf2c70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ node_modules storage/* -.idea/* .DS_Store _plugin_shop/ editor/src/templates -.buildpath -.dropbox -.project -.settings -Icon -*.esproj/ .vscode/settings.json -TODO diff --git a/.htaccess b/.htaccess index 1976ac7b0..fae6d760a 100644 --- a/.htaccess +++ b/.htaccess @@ -47,6 +47,8 @@ RewriteCond %{REQUEST_URI} !^/engine/editor.* RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ /engine/dist/index.html [L] +# A site's private shop files: invoices and payment records +RewriteCond %{REQUEST_URI} ^/storage/(-sites/[^/]+/)?_shop_files/ [OR] RewriteCond %{REQUEST_URI} ^/storage.*\.(xml|sqlite|db|log|php)$ [OR] RewriteCond %{REQUEST_URI} ^/storage/$ [OR] RewriteCond %{REQUEST_URI} ^/_templates/$ @@ -71,6 +73,7 @@ RewriteRule .* - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_URI} !index.php$ +RewriteCond %{REQUEST_URI} !/sitemap\.xml$ RewriteCond %{REQUEST_URI} !^(.*)/$ RewriteRule ^(.*)$ /$1/ [R=301,L] diff --git a/LocalValetDriver.php b/LocalValetDriver.php index ca00951b7..416be1970 100644 --- a/LocalValetDriver.php +++ b/LocalValetDriver.php @@ -110,7 +110,12 @@ private function resolve(string $sitePath, string $uri): array return [self::STATIC, $sitePath . '/engine/dist/index.html']; } - if (preg_match('#^/storage.*\.(xml|sqlite|db|log|php)$#', $uri) || $uri === '/storage/' || $uri === '/_templates/') { + if ( + preg_match('#^/storage/(-sites/[^/]+/)?_shop_files/#', $uri) + || preg_match('#^/storage.*\.(xml|sqlite|db|log|php)$#', $uri) + || $uri === '/storage/' + || $uri === '/_templates/' + ) { return [self::NOT_FOUND, null]; } @@ -118,7 +123,7 @@ private function resolve(string $sitePath, string $uri): array return $this->passthroughRoute($path, $uri); } - if (! $this->isActualFile($path) && ! $this->endsWith($uri, 'index.php') && ! $this->endsWith($uri, '/')) { + if (! $this->isActualFile($path) && ! $this->endsWith($uri, 'index.php') && ! $this->endsWith($uri, '/sitemap.xml') && ! $this->endsWith($uri, '/')) { return [self::REDIRECT, $uri . '/']; } 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/Configuration/SiteSettingsConfigService.php b/_api_app/app/Configuration/SiteSettingsConfigService.php index aa8130ae1..bda6f0e11 100644 --- a/_api_app/app/Configuration/SiteSettingsConfigService.php +++ b/_api_app/app/Configuration/SiteSettingsConfigService.php @@ -3,7 +3,7 @@ namespace App\Configuration; use App\Shared\I18n; -use Illuminate\Support\Facades\Auth; +use App\User\UserModel; class SiteSettingsConfigService { @@ -22,38 +22,17 @@ public function get($lang = 'en') return $this->settings; } - /* Setup for the @@@HACK: */ I18n::load_language($lang); - if (! isset($options)) { - $options = []; - } - $berta = (object) ['security' => (object) ['userLoggedIn' => Auth::check()]]; - $ENGINE_ROOT_PATH = config('app.old_berta_root') . '/engine/'; - $SITE_ROOT_PATH = config('app.old_berta_root') . '/'; - $options['XML_ROOT'] = "{$SITE_ROOT_PATH}storage/"; - $conf = file_get_contents( - realpath(config('app.old_berta_root') . '/engine/inc.settings.php') + // The page's settings: the shop's own settings come from the shop plugin + $this->settings = (require config('app.old_berta_root') . '/engine/inc.setting-definition.php')( + I18n::_(...), + false, + UserModel::hostingPlan(), ); - - /** - * @todo Fix this HACK, also read settings definitions from shop plugin - */ - // @@@:HACK: read in settings config and set up namespace - // so that I18n would be visible there - $conf = str_replace('settings = $settingsDefinition; $this->settings['template']['template']['values'] = $this->getTemplates(); $this->settings['template']['template']['default'] = $this->defaultTemplate; - // Make sure `shop` settings don't mix with the page settings. - // @TODO remove this once we clean up the settings - if (isset($this->settings['shop'])) { - unset($this->settings['shop']); - } - return $this->settings; } diff --git a/_api_app/app/Configuration/SiteTemplatesConfigService.php b/_api_app/app/Configuration/SiteTemplatesConfigService.php index f7e9f1e68..666af745c 100644 --- a/_api_app/app/Configuration/SiteTemplatesConfigService.php +++ b/_api_app/app/Configuration/SiteTemplatesConfigService.php @@ -228,23 +228,14 @@ public function get($lang = 'en') $ret = []; I18n::load_language($lang); - foreach ($this->getAllTemplates() as $tpl) { - $ret[$tpl] = []; - $conf = file_get_contents( - $this->TEMPLATE_ROOT . '/' . $tpl . '/template.conf.php' - ); + $isShopEnabled = Helpers::isShopEnabled(request()->getHost()); - /** - * @todo Fix this HACK - */ - // @@@:HACK: read in template config and set up namespace - // so that I18n would be visible there - $conf = str_replace('getAllTemplates() as $tpl) { [ $ret[$tpl]['sectionTypes'], $ret[$tpl]['templateConf'], - $ret[$tpl]['translations'] - ] = eval($conf); + $ret[$tpl]['translations'], + ] = (require "{$this->TEMPLATE_ROOT}/{$tpl}/template.conf.php")(I18n::_(...), $isShopEnabled); } return $ret; diff --git a/_api_app/app/Shared/Helpers.php b/_api_app/app/Shared/Helpers.php index 5a56f25c0..83d48a317 100644 --- a/_api_app/app/Shared/Helpers.php +++ b/_api_app/app/Shared/Helpers.php @@ -593,6 +593,25 @@ public static function arrayToHtmlAttributes($attributes) return $html; } + /** + * A page's address on the site: its site, section and tag, ending in a slash. That's the form the + * web server answers, .htaccess redirects every other one to it. + * + * @param array $urlParts The site, section and tag names the address has, none for the front page + */ + public static function sitePath(array $urlParts, bool $isPreviewMode = false): string + { + return '/' . ($urlParts ? implode('/', $urlParts) . '/' : '') . ($isPreviewMode ? '?preview=1' : ''); + } + + /** + * 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/Shared/I18n.php b/_api_app/app/Shared/I18n.php index 570474ad6..ddcb5da8f 100644 --- a/_api_app/app/Shared/I18n.php +++ b/_api_app/app/Shared/I18n.php @@ -2,10 +2,21 @@ namespace App\Shared; +/** + * Translations of the editor's texts, from the `engine/lang` files. A text is looked up by its key: + * either the English text itself, or a key whose English text is in `en.php`. + */ class I18n { public static $translations; + /** + * The English texts of the keys, for a language that has no translation of a key + * + * @var array + */ + public static $fallback = []; + public static $default_lang = 'en'; public static function load_language($lang = null) @@ -17,6 +28,9 @@ public static function load_language($lang = null) } elseif (file_exists($ENGINE_ROOT_PATH . '/lang/' . self::$default_lang . '.php')) { self::$translations = include $ENGINE_ROOT_PATH . '/lang/' . self::$default_lang . '.php'; } + + $englishPath = $ENGINE_ROOT_PATH . '/lang/en.php'; + self::$fallback = file_exists($englishPath) ? include $englishPath : []; } /** @@ -25,10 +39,6 @@ public static function load_language($lang = null) */ public static function _($key) { - if (! empty(self::$translations) && isset(self::$translations[$key])) { - return self::$translations[$key]; - } - - return $key; + return self::$translations[$key] ?? self::$fallback[$key] ?? $key; } } diff --git a/_api_app/app/Shared/Storage.php b/_api_app/app/Shared/Storage.php index 8aacaf5f5..6525f2d52 100644 --- a/_api_app/app/Shared/Storage.php +++ b/_api_app/app/Shared/Storage.php @@ -8,6 +8,13 @@ class Storage { + /** + * A site's private shop files, kept next to its shop database so they move, swap and back up with it. + * The web server denies this folder at the top of a site's storage only (.htaccess, LocalValetDriver.php), + * so a site or media folder that happens to share the name stays reachable. + */ + public const SHOP_FILES_FOLDER = '_shop_files'; + /** @var array * Associative array representing data structure handled by this service. */ @@ -297,7 +304,10 @@ protected function xmlFile2array($xml_file) return []; } - protected function copyFolder($src, $dst) + /** + * @param list $skipFolders Folders at the top of $src left out of the copy + */ + protected function copyFolder($src, $dst, array $skipFolders = []) { if (! is_dir($src)) { return; @@ -309,7 +319,7 @@ protected function copyFolder($src, $dst) while (false !== ($file = readdir($dir))) { if (($file != '.') && ($file != '..')) { if (is_dir($src . '/' . $file)) { - if (! in_array($file, ['-sites', $this->PREVIEW_FOLDER])) { + if (! in_array($file, ['-sites', $this->PREVIEW_FOLDER, ...$skipFolders])) { self::copyFolder($src . '/' . $file, $dst . '/' . $file); } } else { diff --git a/_api_app/app/Shop/shoppingCart.twig b/_api_app/app/Shop/shoppingCart.twig index 88b4a5703..9fb3e3f16 100644 --- a/_api_app/app/Shop/shoppingCart.twig +++ b/_api_app/app/Shop/shoppingCart.twig @@ -1,503 +1,292 @@ -
- +{# + The shop's cart, which shop.js loads from the cart section (?shopping_cart=1) into its dialog. The site has no page + of its own for the cart section, the editor shows it to edit the cart's texts. + + `cart-*` classes style the cart, `js-cart-*` classes are shop.js's hooks and carry no styles. In the editor the + same markup shows every part of the cart, with its texts editable in place. +#} +{% macro text(text, caption, isEditMode, tag) %} + {%- set tag = tag ?: 'span' -%} + <{{ tag }}{{ text.attributes|raw }}> + {%- if text.content %}{{ text.content }}{% elseif isEditMode %} {{ caption }} {% endif -%} + +{%- endmacro %} +{% import _self as cart %} +{# A shop with PayPal's REST credentials gets PayPal's buttons, which start with these #} +
+

{{ cartTitle }}

+ +
+ {{ cart.text(emptyCart, 'Shopping cart empty', isEditMode, 'div') }}
-
{{ cartTitle }}
+
+ {{ cart.text(expiredCart, 'Shopping cart is expired', isEditMode, 'div') }} +
- + -
-
-
- {% if isEditMode %} - - {% if tableHeadTitle.content %}{{ tableHeadTitle.content }}{% else %} title {% endif %} -
- {% else %} - {{ tableHeadTitle.content }} - {% endif %} -
+
+
+
{{ cart.text(tableHeadTitle, 'Title', isEditMode) }}
+
{{ cart.text(tableHeadQuantity, 'Quantity', isEditMode) }}
+
{{ cart.text(tableHeadPrice, 'Price', isEditMode) }}
+
{{ cart.text(tableHeadSum, 'Sum', isEditMode) }} ({{ currency }})
+
-
-
-
- {% if isEditMode %} - - {% if tableHeadQuantity.content %}{{ tableHeadQuantity.content }}{% else %} quantity {% endif %} -
- {% else %} - {{ tableHeadQuantity.content }} - {% endif %} -
-
- {% if isEditMode %} - - {% if tableHeadPrice.content %}{{ tableHeadPrice.content }}{% else %} price {% endif %} -
- {% else %} - {{ tableHeadPrice.content }} - {% endif %} -
-
+
    + + {# Each product in the cart gets a copy of this line #} + + +
    + {% if discountAvailable %} +
    + +
    + {% if isEditMode %} - - {% if tableHeadSum.content %}{{ tableHeadSum.content }}{% else %} sum {% endif %} -
    +
    {{ cart.text(promoButton, 'OK', isEditMode) }}
    {% else %} - {{ tableHeadSum.content }} + {% endif %} - ({{ currency }})
    + {% endif %} + +
    + +
    -
    -
    -
    -
    -
    -
    + -
    -
    -
    -
    -
    +
    +
    +
    {{ cart.text(shippingTotal, 'Shipping', isEditMode) }}
    +
    {{ currency }}
    -
    - -
    -
    - {% if discountAvailable %} -
    - {% if isEditMode %} - - {% if promoCode.content %}{{ promoCode.content }}{% else %} Promo code {% endif %} -
    - {% else %} - {{ promoCode.content }} - {% endif %} - - - {% if isEditMode %} - - {% if promoButton.content %}{{ promoButton.content }}{% else %} OK {% endif %} -
    - {% else %} - - {% endif %} - -
    - {% endif %} +
    +
    {{ cart.text(discount, 'Discount', isEditMode) }}
    +
    +
    +
    +
    {{ cart.text(total, 'Total', isEditMode) }}
    +
    {{ currency }}
    +
    +
    +
    {{ cart.text(includedVat, 'Incl vat', isEditMode) }} (%)
    +
    {{ currency }}
    + +
    -
    -
    - - {{ shippingTo.content }} -
    +
    +
    +

    {{ cart.text(billingAddress, 'Billing address', isEditMode) }}

    + + - +
    +
    + +
    -
    -
    -
    -
    -
    - - - {{ shippingTotal.content }} - -
    {{ currency }}
    +
    + +
    -
    -
    -
    -
    -
    - - - {% if isEditMode %} - - {% if discount.content %}{{ discount.content }}{% else %} discount {% endif %} -
    - {% else %} - {{ discount.content }} - {% endif %} - +
    + +
    -
    -
    -
    -
    -
    - {% if isEditMode %} - - {% if total.content %}{{ total.content }}{% else %} total {% endif %} -
    - {% else %} - {{ total.content }} - {% endif %} +
    + + +
    - {% if isEditMode %} - - {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %} -
    - {% endif %} - -
    {{ currency }}
    +
    + +
    -
    -
    -
    -
    -
    -
    - -
    - - {{ billingAddress.content }} - - -
    -

    - {% if isEditMode %} - - {% if legalPerson.content %}{{ legalPerson.content }}{% else %} legal person {% endif %} -

    - {% else %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ company.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ companyRegistrationNumber.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ legalAddress.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ nameSurname.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ address.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ phone.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ email.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ comments.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    +
    + + +
    - {% if isEditMode %} - - {% if deliverToBillingAddress.content %}{{ deliverToBillingAddress.content }}{% else %} deliver to billing_address {% endif %} - - {% else %} -

    - -

    - {% endif %} - +
    + +
    -
    - - {% if shippingAddressHeader.content %}{{ shippingAddressHeader.content }}{% else %} shipping address {% endif %} - - -
    -

    -

    - {% else %} - {{ nameSurname.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    -

    - {% else %} - {{ address.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

    - -

    - - {% if not isEditMode %} - - {% endif %} -

    - -

    - - {% if not isEditMode %} - - {% endif %} -

    - -

    - - {% if not isEditMode %} - - {% endif %} -

    - +
    + +
    - {% if isEditMode %} -
    - - {% if receiveNews.content %}{{ receiveNews.content }}{% else %} receive news and updates {% endif %} -
    - - {% elseif receiveNews.content %} -
    -   -
    - {% endif %} +
    + + +
    - -
    -
    - - {{ paymentHeader.content }} - + + -
    +
    +

    {{ cart.text(shippingAddressHeader, 'Shipping address', isEditMode) }}

    - {% if isEditMode or paymentDescription.content %} - - {% if paymentDescription.content %}{{ paymentDescription.content }}{% else %} payment description {% endif %} -

    - {% endif %} +
    +
    + + +
    - - - {% if isPaypalAvailable %} -
    - -
    -
    - -
    - {% endif %} +
    + + +
    - {% if isManualTransferAvailable %} -
    -
    - {% else %} - {{ manualTransfer.content }} - {% endif %} - -
    - {% endif %} +
    + +
    -
    - {% if isEditMode or checkoutDescription.content %} - - {% if checkoutDescription.content %}{{ checkoutDescription.content }}{% else %} checkout description {% endif %} -

    - {% endif %} +
    + + +
    -

    {{ totalLabel }} {{ currency }}

    +
    + + +
    +
    +
    + + {% if isEditMode or receiveNews.content %} + + {% endif %} + +
    +

    {{ cart.text(paymentHeader, 'Choose payment method', isEditMode) }}

    + + {% if isEditMode or paymentDescription.content %} +

    {{ cart.text(paymentDescription, 'Payment description', isEditMode) }}

    + {% endif %} + +
    + {% if isPaypalAvailable %} + - {% if isEditMode %} - {% if config.termsLink %} - - {% if terms.content %}{{ terms.content }}{% else %} By purchasing our products you agree to the Terms of Service {% endif %} -
    - {% endif %} + + {% endif %} - - {% if fillRequiredFields.content %}{{ fillRequiredFields.content }}{% else %} Please fill in all required fields {% endif %} -
    + {% if isManualTransferAvailable %} + + {% endif %} +
    + - - {% if checkoutButton.content %}{{ checkoutButton.content }}{% else %} checkout and pay {% endif %} -
    +
    + {% if isEditMode or checkoutDescription.content %} +

    {{ cart.text(checkoutDescription, 'Checkout description', isEditMode) }}

    + {% endif %} - - {% if returnToStore.content %}{{ returnToStore.content }}{% else %} return to store {% endif %} -
    +
    + {{ totalLabel }} + {{ currency }} +
    + {% if config.termsLink %} +

    + {% if isEditMode %} + {{ cart.text(terms, 'By purchasing our products you agree to the Terms of Service', isEditMode) }} {% else %} - {% if config.termsLink %} -

    {{ terms.content }}

    - {% endif %} - -

    {{ fillRequiredFields.content }}

    + {{ terms.content }} + {% endif %} +

    + {% endif %} -

    {{ checkoutButton.content }}

    + -

    {{ returnToStore.content }}

    - {% endif %} + {# How a PayPal payment went when it didn't go through #} + - - +
    + {% if isEditMode %} +
    {{ cart.text(checkoutButton, 'Checkout and pay', isEditMode) }}
    + {% else %} + + {% endif %}
    + +

    + {% if isEditMode %} + {{ cart.text(returnToStore, 'Return to store', isEditMode) }} + {% else %} + + {% endif %} +

    diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php index 00402b615..740069191 100644 --- a/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php +++ b/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php @@ -185,7 +185,7 @@ public function renderEntries(string $site, string $section, Request $request, ? $siteTemplateSettingsDS->getState(), (new Storage($site)), false, - Helpers::isValidDomain($request->getHost(), config('plugin-Shop.key')) + Helpers::isShopEnabled($request->getHost()) ); } diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php index 6f5250335..d1464c5bb 100644 --- a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php +++ b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php @@ -312,7 +312,7 @@ public function render( $storageService, $isEditMode, $isShopAvailable - ) { + ): string { $data = $this->getViewData( $site, $sections, @@ -325,6 +325,6 @@ public function render( $isShopAvailable ); - return view('Sites/Sections/Entries/entry', $data); + return view('Sites/Sections/Entries/entry', $data)->render(); } } diff --git a/_api_app/app/Sites/Sections/Entries/SectionMashupEntriesRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionMashupEntriesRenderService.php index f6cc8dbed..9e001cb27 100644 --- a/_api_app/app/Sites/Sections/Entries/SectionMashupEntriesRenderService.php +++ b/_api_app/app/Sites/Sections/Entries/SectionMashupEntriesRenderService.php @@ -123,7 +123,7 @@ private function getContent($entry, $storageService, $siteSettings, $siteTemplat $urlParts[] = Helpers::slugify($entry['tags']['tag'][0], '-', '-'); } - $url = '/' . implode('/', $urlParts) . ($isPreviewMode ? '?preview=1' : ''); + $url = Helpers::sitePath($urlParts, $isPreviewMode); } $galleryItem = ImageHelpers::getGalleryItem( diff --git a/_api_app/app/Sites/Sections/Entries/shop/_addToCart.twig b/_api_app/app/Sites/Sections/Entries/shop/_addToCart.twig index 3bd35b0aa..c78b646ee 100644 --- a/_api_app/app/Sites/Sections/Entries/shop/_addToCart.twig +++ b/_api_app/app/Sites/Sections/Entries/shop/_addToCart.twig @@ -16,7 +16,7 @@ {% endif %} - {{ addToBasketLabel }} - + + diff --git a/_api_app/app/Sites/Sections/GridViewRenderService.php b/_api_app/app/Sites/Sections/GridViewRenderService.php index e6dc2d769..0dd22771f 100644 --- a/_api_app/app/Sites/Sections/GridViewRenderService.php +++ b/_api_app/app/Sites/Sections/GridViewRenderService.php @@ -2,6 +2,8 @@ namespace App\Sites\Sections; +use App\Shared\Helpers; + class GridViewRenderService { private $USED_IN_TEMPLATES = ['messy']; @@ -31,7 +33,7 @@ private function getUrl($siteSlug, $currentSection, $tagSlug, $isPreviewMode, $i return '?' . implode('&', $parts); } else { - return '/' . implode('/', $urlParts) . ($isPreviewMode ? '?preview=1' : ''); + return Helpers::sitePath($urlParts, $isPreviewMode); } } diff --git a/_api_app/app/Sites/Sections/MessyTemplateRenderService.php b/_api_app/app/Sites/Sections/MessyTemplateRenderService.php index 4aba9097d..92699b64a 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); @@ -342,7 +341,7 @@ private function getGridTrigger($request, $siteSlug, $sections, $currentSection, } $link = '?' . implode('&', $parts); } else { - $link = '/' . implode('/', $urlParts) . ($isPreviewMode ? '?preview=1' : ''); + $link = Helpers::sitePath($urlParts, $isPreviewMode); } return [ @@ -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..b7d77433e 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/SectionsMenuRenderService.php b/_api_app/app/Sites/Sections/SectionsMenuRenderService.php index 4fdb2afdc..2f2a4a00d 100644 --- a/_api_app/app/Sites/Sections/SectionsMenuRenderService.php +++ b/_api_app/app/Sites/Sections/SectionsMenuRenderService.php @@ -213,7 +213,7 @@ private function getUrl($section, $site, $sections, $siteSettings, $isEditMode, return '?' . implode('&', $parts); } else { - return '/' . implode('/', $urlParts) . ($isPreviewMode ? '?preview=1' : ''); + return Helpers::sitePath($urlParts, $isPreviewMode); } } 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/SitemapRenderService.php b/_api_app/app/Sites/Sections/SitemapRenderService.php index 3ab93b9ee..1f0db13a7 100644 --- a/_api_app/app/Sites/Sections/SitemapRenderService.php +++ b/_api_app/app/Sites/Sections/SitemapRenderService.php @@ -2,6 +2,8 @@ namespace App\Sites\Sections; +use App\Shared\Helpers; + class SitemapRenderService { private function getTags($sectionTags) @@ -42,7 +44,7 @@ private function getUrl($section, $siteSlug, $sections, $request, ?string $tag = $host = $request->getHost(); $scheme = $request->getScheme(); - return "{$scheme}://{$host}/" . implode('/', $urlParts); + return "{$scheme}://{$host}" . Helpers::sitePath($urlParts); } private function getViewData( 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..03d1bb572 100644 --- a/_api_app/app/Sites/SitesDataService.php +++ b/_api_app/app/Sites/SitesDataService.php @@ -3,6 +3,7 @@ namespace App\Sites; use App\Plugins\Shop\ShopClientsDataService; +use App\Plugins\Shop\ShopOrderNumbersDataService; use App\Plugins\Shop\ShopOrdersDataService; use App\Plugins\Shop\ShopProductsDataService; use App\Shared\Helpers; @@ -165,7 +166,8 @@ public function create(Request $request, ?string $cloneFrom = null) } $dir = $this->XML_SITES_ROOT . '/' . $name; - $this->copyFolder($src, $dir); + // The shop files belong to the source's orders, which the clone doesn't keep + $this->copyFolder($src, $dir, [self::SHOP_FILES_FOLDER]); } else { @mkdir($dir, 0777, true); } @@ -183,11 +185,14 @@ 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); $ordersDataService->TruncateOrders(); + // A cloned site's order numbers start from 1 + $orderNumbersDataService = new ShopOrderNumbersDataService($name); + $orderNumbersDataService->TruncateOrderNumbers(); $productsDataService = new ShopProductsDataService($name); $productsDataService->ClearReservation(); } @@ -277,7 +282,7 @@ public function createPrieview($themeName) // generate new preview content // for now just copy all the existing contents to preview - $this->copyFolder($this->XML_STORAGE_ROOT, $this->XML_PREVIEW_ROOT); + $this->copyFolder($this->XML_STORAGE_ROOT, $this->XML_PREVIEW_ROOT, [self::SHOP_FILES_FOLDER]); // Merge site settings $siteSettingsDS = new SiteSettingsDataService($this->SITE, $this->XML_PREVIEW_ROOT); diff --git a/_api_app/app/Sites/SitesHeaderRenderService.php b/_api_app/app/Sites/SitesHeaderRenderService.php index a91367139..0ad0396ab 100644 --- a/_api_app/app/Sites/SitesHeaderRenderService.php +++ b/_api_app/app/Sites/SitesHeaderRenderService.php @@ -100,7 +100,7 @@ private function getUrl($siteName, $isEditMode, $isPreviewMode) return '?' . implode('&', $parts); } else { - return '/' . implode('/', $urlParts) . ($isPreviewMode ? '?preview=1' : ''); + return Helpers::sitePath($urlParts, $isPreviewMode); } } diff --git a/_api_app/app/Sites/SitesMenuRenderService.php b/_api_app/app/Sites/SitesMenuRenderService.php index ac4499f73..111c4abb2 100644 --- a/_api_app/app/Sites/SitesMenuRenderService.php +++ b/_api_app/app/Sites/SitesMenuRenderService.php @@ -62,7 +62,7 @@ private function getViewData( if ($isEditMode) { $link = ! empty($site['name']) ? './?site=' . $site['name'] : './'; } else { - $link = '/' . $site['name']; + $link = Helpers::sitePath(array_filter([$site['name']])); } return [ diff --git a/_api_app/app/User/UserModel.php b/_api_app/app/User/UserModel.php index e46f866dc..55379e843 100644 --- a/_api_app/app/User/UserModel.php +++ b/_api_app/app/User/UserModel.php @@ -67,6 +67,14 @@ public function getAuthIdentifier() } public function getPlan() + { + return self::hostingPlan(); + } + + /** + * The site's Berta hosting plan, null when it isn't hosted by Berta + */ + public static function hostingPlan(): ?int { $path = config('app.old_berta_root') . '/engine/plan'; if (! file_exists($path)) { diff --git a/_api_app/composer.json b/_api_app/composer.json index f131c5ee1..4e6296911 100644 --- a/_api_app/composer.json +++ b/_api_app/composer.json @@ -18,6 +18,7 @@ "ext-gd": "*", "ext-mbstring": "*", "ext-openssl": "*", + "barryvdh/laravel-dompdf": "^3.1", "firebase/php-jwt": "^7.0", "intervention/image": "^3.11", "laravel/ai": "^1.0", diff --git a/_api_app/composer.lock b/_api_app/composer.lock index cf91b177d..c4b495e5b 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "062d2486fc7d31d808181368b2891a32", + "content-hash": "27814c6f2a0f2dd4cfce7cd20e95ec08", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "brick/math", "version": "1.0.0", @@ -380,6 +457,161 @@ ], "time": "2026-06-14T20:44:06+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" + }, + "time": "2026-07-20T12:29:38+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -2459,6 +2691,73 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "masterminds/html5", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" + }, + "time": "2026-08-18T06:18:41+00:00" + }, { "name": "mobiledetect/mobiledetectlib", "version": "4.11.0", @@ -3953,6 +4252,84 @@ }, "time": "2026-03-20T16:59:18+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.5.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.9", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12", + "phpunit/phpunit": "8.5.54", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.6.2", + "rector/type-perfect": "1.0.0 || 2.1.4", + "squizlabs/php_codesniffer": "4.0.4" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.6.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0" + }, + "time": "2026-09-20T15:02:00+00:00" + }, { "name": "sentry/sentry", "version": "4.32.0", 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..231ad6a23 --- /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,239 @@ +
    +

    Cart

    + +
    +
    Shopping cart is empty
    +
    + +
    +
    Shopping cart is expired
    +
    + + + +
    +
    +
     Title 
    +
     Quantity 
    +
     Price 
    +
     Sum  (EUR)
    +
    + +
      + + + +
      +
      + +
      + +
       OK 
      +
      +
      + +
      + + +
      +
      + +
      +
      +
      Shipping
      +
      EUR
      +
      +
      +
       Discount 
      +
      +
      +
      +
       Total 
      +
      EUR
      +
      +
      +
       Incl vat  (%)
      +
      EUR
      +
      +
      +
      + +
      +
      +

      Billing address

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

       Shipping address 

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

      Choose payment method

      + +

       Payment description 

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

       Checkout description 

      + +
      + Total + EUR +
      + + + + + + +
      +
       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..eb82c3f9d --- /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,239 @@ +
      +

      Cart

      + +
      +
      Custom cartTextEmpty
      +
      + +
      +
      Custom cartTextExpired
      +
      + + + +
      +
      +
      Custom cartTextTitle
      +
      Custom cartTextQuantity
      +
      Custom cartTextPrice
      +
      Custom cartTextSum (EUR)
      +
      + +
        + + + +
        +
        + +
        + +
        Custom cartTextPromoBtn
        +
        +
        + +
        + + +
        +
        + +
        +
        +
        Custom shippingTotalText
        +
        EUR
        +
        +
        +
        Custom cartTextDiscount
        +
        +
        +
        +
        Custom cartTextTotal
        +
        EUR
        +
        +
        +
        Custom cartTextInclVat (%)
        +
        EUR
        +
        +
        +
        + +
        +
        +

        Custom cartTextBillAddr

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

        Custom cartTextShipAddr

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

        Custom cartTextCPM

        + +

        Custom cartTextPaymentDescription

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

        Custom cartTextCheckoutDescription

        + +
        + Custom cartTextTotal + EUR +
        + + + + + + +
        +
        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..d47d7f9a6 --- /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,217 @@ +
        +

        Cart

        + + + + + + + +
        +
        +
        Title
        +
        Quantity
        +
        Price
        +
        Sum (EUR)
        +
        + +
          + + + +
          +
          + +
          + + +
          +
          + +
          + + +
          +
          + +
          +
          +
          Shipping
          +
          EUR
          +
          + +
          +
          Total
          +
          EUR
          +
          + +
          +
          + +
          +
          +

          Billing address

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

          Choose payment method

          + + +
          + + +
          +
          + +
          + +
          + 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__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..a2fdb5a3c --- /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,223 @@ +
          +

          Cart

          + + + + + + + +
          +
          +
          Custom cartTextTitle
          +
          Custom cartTextQuantity
          +
          Custom cartTextPrice
          +
          Custom cartTextSum (EUR)
          +
          + +
            + + + +
            +
            + +
            + + +
            +
            + +
            + + +
            +
            + +
            +
            +
            Custom shippingTotalText
            +
            EUR
            +
            + +
            +
            Custom cartTextTotal
            +
            EUR
            +
            + +
            +
            + +
            +
            +

            Custom cartTextBillAddr

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

            Custom cartTextCPM

            + +

            Custom cartTextPaymentDescription

            + +
            + + +
            +
            + +
            +

            Custom cartTextCheckoutDescription

            + +
            + Custom cartTextTotal + EUR +
            + + + + + + +
            + +
            + +

            + +

            +
            +
            +
            diff --git a/_api_app/tests/Feature/I18nTest.php b/_api_app/tests/Feature/I18nTest.php new file mode 100644 index 000000000..c51f2a9ff --- /dev/null +++ b/_api_app/tests/Feature/I18nTest.php @@ -0,0 +1,33 @@ +bertaRoot = sys_get_temp_dir() . '/berta_i18n_' . uniqid(); + File::ensureDirectoryExists($this->bertaRoot . '/engine/lang'); + File::put($this->bertaRoot . '/engine/lang/en.php', " 'Hello', 'farewell_text' => 'Goodbye'];"); + File::put($this->bertaRoot . '/engine/lang/xx.php', " 'Ardievu', 'Close' => 'Aizvērt'];"); + config(['app.old_berta_root' => $this->bertaRoot]); + + I18n::load_language('xx'); +}); + +afterEach(function () { + File::deleteDirectory($this->bertaRoot); + I18n::$translations = null; + I18n::$fallback = []; +}); + +it('returns the English text of a key the language has no translation of', function () { + expect(I18n::_('greeting_text'))->toBe('Hello'); +}); + +it('returns the language\'s own translation of a key or a text', function () { + expect(I18n::_('farewell_text'))->toBe('Ardievu') + ->and(I18n::_('Close'))->toBe('Aizvērt'); +}); + +it('returns a text that is not a key unchanged', function () { + expect(I18n::_('Save'))->toBe('Save'); +}); 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..d44f2a150 --- /dev/null +++ b/_api_app/tests/Feature/ShopCartRenderServiceTest.php @@ -0,0 +1,183 @@ +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('gives the site script the cart page to open after PayPal, on the site only', function (bool $isEditMode, string $link, ?string $cartUrl) { + $html = (string) (new ShopCartRenderService)->renderCartLink( + Request::create('http://shop.test/'), + 'blog', + [], + [], + [['name' => 'cart', 'title' => 'Cart', '@attributes' => ['type' => 'shopping_cart', 'published' => 1]]], + null, + true, + false, + $isEditMode, + ); + + expect($html)->toContain("href=\"{$link}\""); + $cartUrl === null + ? expect($html)->not->toContain('data-cart-url') + : expect($html)->toContain("data-cart-url=\"{$cartUrl}\""); +})->with([ + 'on the site' => [false, "javascript:openShoppingCart('/blog/cart/');", '/blog/cart/'], + 'in the editor' => [true, '?site=blog§ion=cart', null], +]); + +describe('PayPal buttons', function () { + $restSettings = ['paypalClientId' => 'client-id', 'paypalClientSecret' => 'client-secret', 'paypalCurrencyCode' => 'EUR']; + + it('start with the client ID, script and currency of a shop with REST credentials', function () use ($restSettings) { + Http::fake(); + + expect(renderSiteCart($this->bertaRoot, [...$restSettings, 'paypalSandbox' => 'yes']))->toContain( + '
            ' + ); + Http::assertNothingSent(); + }); + + it('are left out, so the cart keeps the redirect to PayPal', function (array $shop) { + expect(renderSiteCart($this->bertaRoot, $shop))->toContain('
            '); + })->with([ + 'Classic credentials' => [['paypalUserName' => 'seller_api1.berta.test', 'paypalUserPassword' => 'secret', 'paypalSignature' => 'signature']], + 'no PayPal credentials' => [[]], + 'bank transfer only' => [[...$restSettings, 'paymentMethod' => 'bank']], + ]); + + it('are left out in the editor', function () use ($restSettings) { + writeShopSettings($this->bertaRoot, $restSettings); + + $html = (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', + true, + ); + + expect($html)->toContain('
            '); + }); +}); + +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('shows the promo code entry only for a promo code with a discount', function (array $shop, bool $isShown) { + $html = renderSiteCart($this->bertaRoot, $shop); + + expect(str_contains($html, 'id="promo_code"'))->toBe($isShown); +})->with([ + 'a code and a discount' => [['promoCode' => 'SAVE10', 'promoCodeDiscount' => '10'], true], + 'no code' => [['promoCode' => '', 'promoCodeDiscount' => '10'], false], + 'no discount' => [['promoCode' => 'SAVE10', 'promoCodeDiscount' => ''], false], +]); + +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', 'cartTextCardPayment', + 'cartTextCheckoutButton', 'cartTextCheckoutDescription', 'cartTextComments', 'cartTextCompany', + 'cartTextCompanyRegNo', 'cartTextDeliverBilling', 'cartTextDiscount', 'cartTextEmail', 'cartTextEmpty', + 'cartTextExpired', 'cartTextFillRequiredFields', 'cartTextInclVat', 'cartTextLegalAddress', + 'cartTextLegalPerson', 'cartTextNameSurname', 'cartTextPaymentDescription', 'cartTextPaypalPayment', + 'cartTextPhoneNumber', 'cartTextPrice', 'cartTextPromoBtn', 'cartTextPromoCode', 'cartTextQuantity', + 'cartTextReceiveNews', 'cartTextReturnStore', 'cartTextShipAddr', 'cartTextShippingTo', 'cartTextSum', + 'cartTextTerms', 'cartTextTitle', 'cartTextTotal', 'cartTextVatNo', '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..3a8210397 100644 --- a/_api_app/tests/Feature/ShopControllerTest.php +++ b/_api_app/tests/Feature/ShopControllerTest.php @@ -3,16 +3,26 @@ use App\Http\Middleware\Authenticate; use App\Http\Middleware\SetupMiddleware; use App\Models\User; -use App\Plugins\Shop\ShopController; +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; use function Pest\Laravel\actingAs; +use function Pest\Laravel\get; 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 +34,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 +42,33 @@ 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'], + 'order update' => ['PATCH', '/orders'], + 'order invoice' => ['GET', '/orders/1/invoice'], + '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 +77,465 @@ ->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 settings config in the language asked for', function () { + actingAs(new User); + + getJson('http://shop.test' . route('settings-config', ['language' => 'lv'], false)) + ->assertOk() + ->assertJsonPath('data.group_config._.title', 'Konfigurācijas vērtības') + ->assertJsonPath('data.group_config.email.description', 'Obligāts. Tava e-pasta adrese, uz kuru veikals sūta katru jauno pasūtījumu.'); +}); + +it('returns the settings config texts, not translation keys, in every language', function (string $language) { + $texts = collect((new ShopSettingsDataService(''))->getConfig($language)) + ->dot() + ->filter(fn ($value, $path) => preg_match('/\.(title|description)$/', $path)) + ->values(); + + expect($texts)->not->toBeEmpty() + ->and($texts->filter(fn ($text) => preg_match('/^shop_\w+_description$/', (string) $text))->all())->toBe([]); +})->with(['en', 'lv', 'pl', 'es', 'fr', 'nl', 'ru']); + +it('returns 422 for a settings config language that is not a language code', function () { + actingAs(new User); + + getJson('http://shop.test' . route('settings-config', ['language' => '../inc.settings'], false)) + ->assertUnprocessable() + ->assertJsonValidationErrors('language'); +}); + +it('sends the settings config in the site\'s language with the shop state', function () { + $bertaRoot = setUpShopTestRoot(); + actingAs(new User); + (new SiteSettingsDataService(''))->saveValueByPath('/settings/language/language', 'lv'); + + try { + getJson('http://shop.test' . route('shop-state', [], false) . '?site=') + ->assertOk() + ->assertJsonPath('data.settingsConfig.group_config._.title', 'Konfigurācijas vērtības'); + } 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('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]); + + 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(9); + }); +}); + +/** + * 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, statusFilter: string|null} + */ +function getShopOrdersPage(int $page, ?string $status = null): array +{ + $statusQuery = $status === null ? '' : "&status={$status}"; + + return getJson('http://shop.test' . route('orders', [], false) . "?site=&page={$page}{$statusQuery}") + ->assertOk() + ->json('data')['']; +} + +function shopOrderInvoiceUrl(int $id, string $site = ''): string +{ + return 'http://shop.test' . route('order-invoice', ['id' => $id], false) . '?site=' . $site; +} + +/** + * @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(); +} + +/** + * @return array + */ +function shopOrderNotes(): array +{ + return (new ShopClientsDataService(''))->query()->orderBy('id')->pluck('admin_notes', 'id')->all(); +} + +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)); + }); + + 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', 'An order paid with PayPal or by debit or credit card 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!'], + '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 () { + 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('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('downloads the invoice issued to the customer unchanged', function () { + writeShopSettings($this->bertaRoot, ['invoices' => 'yes', 'sellerDetails' => 'Riga Prints SIA']); + placeShopOrders(1); + File::ensureDirectoryExists(dirname(shopFile('invoices/invoice-ORD-1.pdf'))); + File::put(shopFile('invoices/invoice-ORD-1.pdf'), '%PDF-issued'); + + $response = get(shopOrderInvoiceUrl(1)); + + $response->assertDownload('invoice-ORD-1.pdf')->assertHeader('Content-Type', 'application/pdf'); + expect($response->streamedContent())->toBe('%PDF-issued'); + }); + + it('makes and keeps the invoice of an order that has none', function () { + writeShopSettings($this->bertaRoot, ['invoices' => 'yes', 'sellerDetails' => 'Riga Prints SIA']); + placeShopOrders(1); + updateShopOrders([1 => ['name' => 'Ann Buyer', 'price_total' => 15, 'shipping' => 5, 'vat' => 21, 'payment_method' => 'bank_transfer', 'paypal' => 0]]); + + $response = get(shopOrderInvoiceUrl(1)); + + $response->assertDownload('invoice-ORD-1.pdf'); + expect($response->streamedContent()) + ->toStartWith('%PDF-') + ->toBe(File::get(shopFile('invoices/invoice-ORD-1.pdf'))); + }); + + it('returns 404 for an invoice when invoices are off or the shop has no seller details', function (array $shop) { + writeShopSettings($this->bertaRoot, $shop); + placeShopOrders(1); + + getJson(shopOrderInvoiceUrl(1)) + ->assertNotFound() + ->assertJsonPath('message', 'Invoices are turned off, or the shop has no seller details.'); + + expect(shopFiles())->toBe([]); + })->with([ + 'invoices off' => [['sellerDetails' => 'Riga Prints SIA']], + 'no seller details' => [['invoices' => 'yes']], + ]); + + it('returns 404 for the invoice of no order', function (int $id, string $site, string $message) { + writeShopSettings($this->bertaRoot, ['invoices' => 'yes', 'sellerDetails' => 'Riga Prints SIA']); + placeShopOrders(1); + + getJson(shopOrderInvoiceUrl($id, $site)) + ->assertNotFound() + ->assertJsonPath('message', $message); + })->with([ + 'an unknown order' => [99, '', 'Order not found!'], + 'a basket' => [2, '', 'Order not found!'], + 'an unknown site' => [1, 'elsewhere', 'Site not found!'], + ]); + + it('adds an empty status, private notes, payment method, VAT number and payment reference 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($page['orders'][0])->toHaveKey('admin_notes', null) + ->and($page['orders'][0])->toHaveKey('payment_method', null) + ->and($page['orders'][0])->toHaveKey('vat_no', null) + ->and($page['orders'][0])->toHaveKey('payment_reference', null) + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(9); + }); +}); diff --git a/_api_app/tests/Feature/ShopInvoiceTest.php b/_api_app/tests/Feature/ShopInvoiceTest.php new file mode 100644 index 000000000..cd39afa59 --- /dev/null +++ b/_api_app/tests/Feature/ShopInvoiceTest.php @@ -0,0 +1,142 @@ +bertaRoot = setUpShopTestRoot(); +}); + +afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } +}); + +/** + * A checked-out order of a chair for 100 and a T-shirt for 25, with a 10% promo discount and 10 shipping, 21% VAT + * included, as the shop admin finds it + * + * @param array $columns The order's `client` columns besides the amounts + */ +function savedShopOrder(array $columns = []): object +{ + (new ShopClientsDataService(''))->query()->insert([ + 'id' => 1, + 'created_at' => '2026-10-12 10:20:00', + 'session_id' => 'checkout', + 'order_id' => '2026-7', + 'name' => 'Ann Buyer', + 'phone' => '+371 20000000', + 'address' => 'Brivibas 1, Riga', + 'email' => 'ann@buyer.test', + 'vat' => 21, + 'shipping' => 10, + 'price_total' => 122.5, + 'promo_code' => 'SAVE10', + 'paypal' => 0, + 'payment_method' => 'bank_transfer', + ...$columns, + ]); + (new ShopProductsDataService(''))->query()->insert([ + ['id' => 1, 'uniqid' => 'chair', 'name' => 'Chair'], + ['id' => 2, 'uniqid' => 'tshirt', 'name' => 'T-shirt M'], + ]); + (new ShopOrdersDataService(''))->query()->insert([ + ['id' => 1, 'created_at' => '2026-10-12 10:00:00', 'id_client' => 1, 'id_product' => 1, 'paid' => 1, 'price' => 100], + ['id' => 2, 'created_at' => '2026-10-12 10:00:00', 'id_client' => 1, 'id_product' => 2, 'paid' => 1, 'price' => 25], + ]); + + return (new ShopClientsDataService(''))->findOrder(1); +} + +function savedShopOrderInvoice(object $order): string +{ + $shop = ['currency' => 'EUR', 'email' => 'seller@berta.test', 'sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]; + + return ShopInvoice::fromSavedOrder($order, $shop, 'Riga Prints')->html(); +} + +/** + * The invoice's text as its reader sees it, without the markup + */ +function shopInvoiceText(string $html): string +{ + return preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($html), ENT_QUOTES)); +} + +it('shows the seller, the amounts and the bank transfer of a saved order', function () { + $order = savedShopOrder(); + + $html = savedShopOrderInvoice($order); + + expect(shopInvoiceText($html)) + ->toContain('Invoice 2026-7 12.10.2026 Riga Prints') + ->toContain('Chair 1 100.00 100.00') + ->toContain('T-shirt M 1 25.00 25.00') + ->toContain('Subtotal 125.00 Discount (10%) -12.50 Shipping 10.00 Total excl. VAT 101.24 VAT (21%) 21.26 Total 122.50 EUR') + ->toContain('Please pay 122.50 EUR by bank transfer to the account in the seller details, with the payment reference 2026-7.') + ->not->toContain('seller@berta.test') + ->not->toContain('Paid '); + expect($html) + ->toContain('Riga Prints SIA
            ' . "\n" . 'IBAN LV80BANK0000435195001') + ->toContain('Ann Buyer
            Brivibas 1, Riga
            +371 20000000
            ann@buyer.test'); +}); + +it('shows the company details of a legal person, and the shipping address of an order shipped elsewhere', function () { + $order = savedShopOrder([ + 'company' => 'Buyer SIA', + 'company_reg_no' => '40003000000', + 'vat_no' => 'LV40003000000', + 'legal_address' => 'Brivibas 2, Riga', + 'ship_name' => 'Juris Buyer', + 'ship_address' => 'Lacplesa 5, Riga', + ]); + + $html = savedShopOrderInvoice($order); + + expect($html) + ->toContain('Buyer SIA
            Reg. no. 40003000000
            VAT no. LV40003000000
            Legal address: Brivibas 2, Riga
            Ann Buyer') + ->toContain('SHIPPING ADDRESS

            ') + ->toContain('Juris Buyer
            Lacplesa 5, Riga') + ->not->toContain('Same as billing address'); +}); + +it('shows how and when a paid order was paid', function (array $payment, array $paid) { + $order = savedShopOrder($payment); + + $text = shopInvoiceText(savedShopOrderInvoice($order)); + + expect($text) + ->toContain(...$paid) + ->not->toContain('Please pay'); +})->with([ + 'with PayPal' => [ + ['payment_method' => 'paypal', 'paypal' => 1, 'payment_reference' => '3C679366HH908993F'], + ['PAYMENT Paid with PayPal on 12.10.2026.', 'PayPal transaction 3C679366HH908993F'], + ], + 'by card' => [ + ['payment_method' => 'creditcard', 'paypal' => 1, 'payment_reference' => '3C679366HH908993F'], + ['PAYMENT Paid by debit or credit card on 12.10.2026.'], + ], + 'an order placed before the payment method was saved' => [ + ['payment_method' => null, 'paypal' => 1], + ['PAYMENT Paid with PayPal on 12.10.2026.'], + ], +]); + +it('escapes the customer details', function () { + $order = savedShopOrder(['name' => 'Ann', 'company' => 'Buyer & Co', 'company_reg_no' => '1', 'legal_address' => 'Riga']); + + $html = savedShopOrderInvoice($order); + + expect($html) + ->toContain('<b>Ann</b>') + ->toContain('Buyer & <i>Co</i>') + ->not->toContain('Ann'); +}); diff --git a/_api_app/tests/Feature/ShopStorefrontControllerTest.php b/_api_app/tests/Feature/ShopStorefrontControllerTest.php new file mode 100644 index 000000000..7ff02e5fd --- /dev/null +++ b/_api_app/tests/Feature/ShopStorefrontControllerTest.php @@ -0,0 +1,1596 @@ +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']; +} + +/** + * Invoices turned on, with the seller details they need + */ +function invoiceSettings(): array +{ + return ['invoices' => 'yes', 'sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]; +} + +/** + * A second site of a multisite, selling the main site's products + * + * @param array $shop + */ +function setUpSecondShopSite(string $bertaRoot, array $shop = []): void +{ + writeShopSettings($bertaRoot, $shop, 'second'); + File::put($bertaRoot . '/storage/-sites/sites.xml', '' + . '<![CDATA[Main]]>' + . '<![CDATA[Second]]>' + . ''); + File::copy($bertaRoot . '/storage/sections.xml', $bertaRoot . '/storage/-sites/second/sections.xml'); + File::copy($bertaRoot . '/storage/blog.shop.xml', $bertaRoot . '/storage/-sites/second/blog.shop.xml'); + (new ShopShippingRegionsDataService('second'))->addRegion('Europe', 21); +} + +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, string $site = ''): TestResponse +{ + return asShopCustomer($token, $site) + ->postJson(storefrontUrl('basket', array_filter(['site' => $site])), ['uniqid' => $uniqid, 'attribute' => $attribute]); +} + +/** + * Fills a new basket and returns its cookie token + * + * @param list $items + */ +function fillShopBasket(array $items, string $site = ''): string +{ + $token = null; + foreach ($items as $item) { + $token = addToShopBasket($item[0], $item[1] ?? null, $token, $site) + ->assertOk() + ->getCookie(ShopBasketService::clientCookie($site), 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)); +} + +/** + * 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']) { + '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'])); +} + +/** + * The site's front page, where PayPal's return sends the customer of a paid order with its number + */ +function paidReturnUrl(string $orderId): string +{ + return 'http://shop.test/?shop_payment=paid&shop_order=' . $orderId; +} + +/** + * The number of the year's nth order + */ +function orderNumber(int $nth): string +{ + return now()->year . "-{$nth}"; +} + +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 () { + setUpSecondShopSite($this->bertaRoot); + + // 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('numbers the orders of each site of a multisite on their own, and keeps their invoices apart', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + writeShopSettings($this->bertaRoot, invoiceSettings()); + setUpSecondShopSite($this->bertaRoot, invoiceSettings()); + shopCheckout(fillShopBasket([[SHOP_CHAIR]]))->assertOk()->assertJsonPath('data.order_id', '2026-1'); + + shopCheckout(fillShopBasket([[SHOP_CHAIR]], 'second'), [], 'second')->assertOk()->assertJsonPath('data.order_id', '2026-1'); + + expect(shopFiles())->toBe(['invoices/invoice-2026-1.pdf']) + ->and(shopFiles('second'))->toBe(['invoices/invoice-2026-1.pdf']); +}); + +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) + ->payment_method->toBe('bank_transfer') + ->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(); + + 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('numbers the orders of each year from 1, also orders placed in the same second', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-12-31 23:59: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'), + ]; + travelTo(Carbon::parse('2027-01-01 00:01:00')); + + $numbers[] = shopCheckout(fillShopBasket([[SHOP_TSHIRT, 'M']]))->assertOk()->json('data.order_id'); + + expect($numbers)->toBe(['2026-1', '2026-2', '2027-1']) + ->and(shopDb()->table('client')->where('session_id', 'checkout')->orderBy('id')->pluck('order_id')->all())->toBe($numbers); + }); + + it('dates an order when it is placed, not when its basket was last renewed', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + $token = fillShopBasket([[SHOP_CHAIR]]); + travelTo(Carbon::parse('2026-10-12 10:20:00')); + + shopCheckout($token)->assertOk(); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->value('created_at'))->toBe('2026-10-12 10:20:00'); + }); + + it('saves the VAT number of a legal person only, and emails it with the company details', function (bool $isLegalPerson, ?string $savedVatNumber) { + Mail::fake(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, [ + 'legal_person' => $isLegalPerson, + 'company' => 'Riga Prints SIA', + 'company_reg_no' => '40003000000', + 'vat_no' => 'LV40003000000', + 'legal_address' => 'Brivibas 2, Riga', + ])->assertOk(); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->value('vat_no'))->toBe($savedVatNumber); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller + && str_contains($mail->render(), 'Reg. no. 40003000000
            VAT no. LV40003000000
            ') === $isLegalPerson); + })->with([ + 'a legal person' => [true, 'LV40003000000'], + 'a private person' => [false, null], + ]); + + it('takes a legal person without a VAT number', function () { + Mail::fake(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['legal_person' => true, 'company' => 'Riga Prints SIA', 'company_reg_no' => '40003000000', 'legal_address' => 'Brivibas 2, Riga']) + ->assertOk(); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->first()) + ->company->toBe('Riga Prints SIA') + ->vat_no->toBeNull(); + }); + + 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(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, 'PayPal transaction') + && ! 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]]); + + 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) { + $text = shopMailText($mail); + + return $mail->totals['vat_percent'] === 25.5 + && str_contains($text, 'Total excl. VAT 83.67') + && str_contains($text, '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('gives the number of an order that fails to save to the next order', function () { + Mail::fake(); + Exceptions::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + $failingToken = fillShopBasket([[SHOP_CHAIR]]); + $token = fillShopBasket([[SHOP_TSHIRT, 'S']]); + 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($failingToken)->assertInternalServerError(); + shopDb()->statement('DROP TRIGGER fail_checkout'); + + shopCheckout($token)->assertOk()->assertJsonPath('data.order_id', '2026-1'); + }); + + it('places the order when sending its emails fails', function () { + Exceptions::fake(); + config(['plugin-Shop.mailer.transport' => 'smtp', '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('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]]); + + 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']); + }); + + it('requires a shipping region', function () { + Mail::fake(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['region' => '']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['region' => 'The region field is required.']); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); + Mail::assertNothingSent(); + }); + + it('places a basket checked out twice at once only once', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + $token = fillShopBasket([[SHOP_CHAIR]]); + $basket = new ShopBasketService(''); + $checkout = new ShopCheckoutService('', $basket); + // The second checkout, e.g. from another tab, found the basket open before the first one placed it + $client = $basket->findClient($token); + $totals = $checkout->totals($client, 1, ''); + shopCheckout($token)->assertOk()->assertJsonPath('data.order_id', '2026-1'); + + $orderId = $checkout->placeManualOrder($client, $checkout->order(['payment_method' => 'bank_transfer']), $totals); + + expect($orderId)->toBeNull(); + expect(shopDb()->table('client')->where('session_id', 'checkout')->pluck('order_id')->all())->toBe(['2026-1']); + expect(shopDb()->table('order_numbers')->value('last_number'))->toEqual(1); + Mail::assertSent(ShopOrderMail::class, 2); + }); +}); + +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_DESC'] === 'Order' + && $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']) + && ! isset($request['LANDINGPAGE'])); + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue() + ->and(shopDb()->table('orders')->where('paid', 1)->count())->toBe(0); + Mail::assertNothingSent(); + }); + + it("starts an NVP card payment on PayPal's guest checkout", function () { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'creditcard']) + ->assertOk() + ->assertJsonPath('data.status', 'redirect'); + + Http::assertSent(fn (HttpRequest $request) => $request['METHOD'] === 'SetExpressCheckout' + && $request['SOLUTIONTYPE'] === 'Sole' + && $request['LANDINGPAGE'] === 'Billing' + && $request['EMAIL'] === 'ann@buyer.test'); + }); + + it('charges in the PayPal currency in capitals, and in US dollars when it is empty', function (string $setting, string $currency) { + writeShopSettings($this->bertaRoot, [...restSettings(), 'paypalCurrencyCode' => $setting]); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + Http::assertSent(fn (HttpRequest $request) => str_ends_with($request->url(), '/v2/checkout/orders') + && $request['purchase_units'][0]['amount']['currency_code'] === $currency); + })->with([ + 'typed in lowercase' => ['eur', 'EUR'], + 'left empty' => ['', 'USD'], + ]); + + 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 or an order number', function () { + writeShopSettings($this->bertaRoot, restSettings(), '', ['pageTitle' => 'Riga Prints']); + // 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]]); + + shopCheckout($token, ['payment_method' => 'creditcard', 'paypal_buttons' => true]) + ->assertOk() + ->assertJsonPath('data.token', SHOP_REST_TOKEN) + ->assertJsonMissingPath('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]['description'] === 'Order from Riga Prints' + && ! isset($request['purchase_units'][0]['custom_id']) + && $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); + + $order = shopDb()->table('client')->where('session_id', 'checkout')->first(); + $response->assertRedirect(paidReturnUrl($order->order_id)); + expect($order)->paypal->toEqual(1)->status->toBe('paid')->price_total->toEqual(105); + expect(File::json(shopFile("paypal/{$paypalToken}.json")))->order_id->toBe($order->order_id); + expect(shopDb()->table('orders')->where('paid', 1)->count())->toBe(1) + ->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('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(paidReturnUrl(orderNumber(1))); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->first()) + ->payment_method->toBe($method) + ->payment_reference->toBe('3C679366HH908993F'); + 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(shopMailText($mail), 'PayPal transaction 3C679366HH908993F') + && ! 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(shopMailText($mail), 'PayPal transaction 3C679366HH908993F') + && ! str_contains($html, '(paypal)') + && shopMailHexHashWords($mail) === []; + }); + })->with([ + 'PayPal' => ['paypal', 'PayPal', 'with PayPal'], + 'card' => ['creditcard', 'Debit or credit card', 'by debit or credit card'], + ]); + + 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(); + expect(shopFiles())->toBe([]); + Mail::assertNothingSent(); + })->with(['nvp', 'rest']); + + it('numbers a PayPal order once it is paid, so an unpaid payment takes no number', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + shopCheckout(fillShopBasket([[SHOP_CHAIR]]), ['payment_method' => 'paypal'])->assertOk(); + shopCheckout(fillShopBasket([[SHOP_TSHIRT, 'S']]))->assertOk()->assertJsonPath('data.order_id', '2026-1'); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(2))); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->pluck('order_id', 'payment_method')->all()) + ->toBe(['paypal' => '2026-2', 'bank_transfer' => '2026-1']); + }); + + 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(paidReturnUrl(orderNumber(1))); + + 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('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(paidReturnUrl(orderNumber(1))); + + 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(); + + 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(paidReturnUrl(orderNumber(1))); + + returnFromPaypal(SHOP_NVP_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); + + 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(paidReturnUrl(orderNumber(1))); + 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('takes the payment on a return after an unconfirmed one, also for products reserved again', 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]]); + // Another customer's order, so the chair's reservation gets a new id when it's reserved again + shopCheckout(fillShopBasket([[SHOP_TSHIRT, 'S']]))->assertOk(); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + // The chair's reservation expires while the customer is at PayPal, the first return reserves it again + travel(31)->minutes(); + postJson(storefrontUrl('stock'), ['uniqids' => [SHOP_CHAIR]])->assertJsonPath('data.0.instock', 1); + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect('http://shop.test/?shop_payment=unconfirmed'); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(2))); + + expect(capturesSent())->toBe(2); + expect(shopDb()->table('orders')->join('product', 'product.id', '=', 'orders.id_product')->pluck('orders.paid', 'product.name')->all()) + ->toEqual(['T-shirt S' => 1, 'Chair' => 1]); + expect(shopDb()->table('product')->where('uniqid', SHOP_CHAIR)->value('instock'))->toEqual(0); + }); + + it('returns 409 for a bank transfer checkout of a basket while PayPal takes its payment', function () { + Mail::fake(); + Sleep::fake(syncWithCarbon: true); + writeShopSettings($this->bertaRoot, restSettings()); + $token = fillShopBasket([[SHOP_CHAIR]]); + $bankCheckout = null; + fakeRest(function () use (&$bankCheckout, $token) { + // The customer places the basket by bank transfer in another tab meanwhile + $bankCheckout = shopCheckout($token); + + return Http::response(paidRestOrder()); + }); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); + + $bankCheckout->assertConflict(); + expect(shopDb()->table('client')->where('session_id', 'checkout')->pluck('payment_method')->all())->toBe(['paypal']); + expect(shopDb()->table('order_numbers')->value('last_number'))->toEqual(1); + Mail::assertSent(ShopOrderMail::class, 2); + }); + + it('emails a paid order that fails to save under its number', function () { + Mail::fake(); + Exceptions::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + 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(paidReturnUrl(orderNumber(1))); + + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->hasTo('seller@berta.test') + && $mail->order['order_id'] === '2026-1'); + 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.transport' => 'smtp', '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(paidReturnUrl(orderNumber(1))); + + 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); + }); + + it('sends the customer back to the page they checked out from with the paid order number', function () { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal', 'return_path' => '/shop/?tag=chairs'])->assertOk(); + + returnFromPaypal(SHOP_NVP_TOKEN) + ->assertRedirect('http://shop.test/shop/?tag=chairs&shop_payment=paid&shop_order=' . orderNumber(1)); + }); + + it('sends the customer who cancels back to the page they checked out from, on the site', function (string $returnPath, string $returnUrl) { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal', 'return_path' => $returnPath])->assertOk(); + + get(storefrontUrl('paypal/cancel', ['token' => SHOP_NVP_TOKEN]))->assertRedirect($returnUrl); + })->with([ + 'a section page' => ['/shop/?tag=chairs', 'http://shop.test/shop/?tag=chairs&shop_payment=cancelled'], + 'a path that looks like another host' => ['//evil.test/', 'http://shop.test//evil.test/?shop_payment=cancelled'], + ]); + + it('refuses a return page that is not a path on the site', function (string $returnPath) { + writeShopSettings($this->bertaRoot, nvpSettings()); + fakeNvp(); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token, ['payment_method' => 'paypal', 'return_path' => $returnPath]) + ->assertUnprocessable() + ->assertJsonValidationErrors('return_path'); + })->with([ + 'another host' => 'https://evil.test/', + 'a userinfo' => '@evil.test/', + 'a header after a line break' => "/shop/\r\nX-Injected: 1", + 'a fragment' => '/shop/#top', + 'a backslash' => '/\evil.test/', + ]); +}); + +describe('PayPal buttons', function () { + it('saves and emails an order paid with the buttons, and returns its number', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + 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') + ->assertJsonPath('data.order_id', '2026-1'); + + expect(shopDb()->table('client')->where('session_id', 'checkout')->value('order_id'))->toBe('2026-1') + ->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') + ->assertJsonPath('data.order_id', null); + + expect(shopDb()->table('client')->where('session_id', $token)->exists())->toBeTrue(); + Mail::assertNothingSent(); + }); + + it('answers a repeated capture with the first outcome and number without capturing again', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + 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') + ->assertJsonPath('data.order_id', '2026-1'); + + 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('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(); + + postJson(storefrontUrl('paypal/capture'), ['token' => '../' . SHOP_REST_TOKEN]) + ->assertOk() + ->assertJsonPath('data.status', 'failed'); + + expect(capturesSent())->toBe(0); + }); +}); + +describe('invoices', function () { + it('attaches the invoice of a bank transfer order to the customer email only, and keeps it', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + writeShopSettings($this->bertaRoot, invoiceSettings()); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + $invoice = File::get(shopFile('invoices/invoice-2026-1.pdf')); + expect($invoice)->toStartWith('%PDF-'); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller + && $mail->hasAttachment(Attachment::fromData(fn () => $invoice, 'invoice-2026-1.pdf')->withMime('application/pdf')) + && str_contains(shopMailText($mail), 'Your invoice is attached.') + && shopMailHexHashWords($mail) === []); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => $mail->forSeller + && $mail->attachments() === [] + && ! str_contains(shopMailText($mail), 'Your invoice is attached.')); + }); + + it('attaches the invoice of a PayPal order once PayPal takes the payment', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + writeShopSettings($this->bertaRoot, [...restSettings(), ...invoiceSettings()]); + fakeRest(); + $token = fillShopBasket([[SHOP_CHAIR]]); + shopCheckout($token, ['payment_method' => 'paypal'])->assertOk(); + + returnFromPaypal(SHOP_REST_TOKEN)->assertRedirect(paidReturnUrl(orderNumber(1))); + + $invoice = File::get(shopFile('invoices/invoice-2026-1.pdf')); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller + && $mail->hasAttachment(Attachment::fromData(fn () => $invoice, 'invoice-2026-1.pdf')->withMime('application/pdf'))); + }); + + it('makes no invoice when invoices are off or the shop has no seller details', function (array $shop) { + Mail::fake(); + writeShopSettings($this->bertaRoot, $shop); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + expect(shopFiles())->toBe([]); + Mail::assertSent(ShopOrderMail::class, fn (ShopOrderMail $mail) => ! $mail->forSeller + && $mail->attachments() === [] + && ! str_contains(shopMailText($mail), 'Your invoice is attached.')); + })->with([ + 'invoices off' => [['sellerDetails' => "Riga Prints SIA\nIBAN LV80BANK0000435195001"]], + 'no seller details' => [['invoices' => 'yes', 'sellerDetails' => ' ']], + ]); + + it('keeps the invoice of an order when the shop sends no emails', function () { + Mail::fake(); + travelTo(Carbon::parse('2026-10-12 10:00:00')); + File::delete($this->bertaRoot . '/engine/hosting'); + writeShopSettings($this->bertaRoot, invoiceSettings()); + $token = fillShopBasket([[SHOP_CHAIR]]); + + shopCheckout($token)->assertOk(); + + expect(shopFiles())->toBe(['invoices/invoice-2026-1.pdf']); + Mail::assertNothingSent(); + }); +}); + +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('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(paidReturnUrl(orderNumber(1))); + actingAs(new User); + + $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']); +}); + +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); + $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', 'status', 'admin_notes', 'payment_method', 'vat_no', 'payment_reference']))->toBeTrue() + ->and($schema->hasColumn('orders', 'weight'))->toBeTrue() + ->and((new SiteSettingsDataService(''))->get()['shop']['version'])->toEqual(9); +}); diff --git a/_api_app/tests/Feature/SiteSettingsConfigServiceTest.php b/_api_app/tests/Feature/SiteSettingsConfigServiceTest.php new file mode 100644 index 000000000..493f6ec18 --- /dev/null +++ b/_api_app/tests/Feature/SiteSettingsConfigServiceTest.php @@ -0,0 +1,12 @@ +get('lv'); + + expect($settings['language']['language']['title'])->toBe('Valoda') + ->and($settings['language']['language']['default'])->toBe('en') + ->and($settings['media']['imagesSmallWidth']['default'])->toBe(200) + ->and($settings)->not->toHaveKey('shop'); +}); diff --git a/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php b/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php new file mode 100644 index 000000000..4cb179c90 --- /dev/null +++ b/_api_app/tests/Feature/SiteTemplatesConfigServiceTest.php @@ -0,0 +1,29 @@ + 'shop.test']); +}); + +it('translates the template settings into the editor\'s language', function () { + $templates = (new SiteTemplatesConfigService)->get('lv'); + + expect($templates['messy-0.4.2']['templateConf']['settings']['_']['title'])->toBe('Dažādi'); +}); + +it('adds the shop\'s section types to a template on a domain with the shop', function (string $host, bool $hasShop) { + if ($hasShop) { + skipWithoutShopPlugin(); + } + app()->instance('request', Request::create("http://{$host}/")); + + $sectionTypes = (new SiteTemplatesConfigService)->get()['messy-0.4.2']['sectionTypes']; + + expect(array_key_exists('shop', $sectionTypes))->toBe($hasShop) + ->and(array_key_exists('shopping_cart', $sectionTypes))->toBe($hasShop); +})->with([ + 'with the shop' => ['shop.test', true], + 'without it' => ['other.test', false], +]); diff --git a/_api_app/tests/Feature/SitemapRenderServiceTest.php b/_api_app/tests/Feature/SitemapRenderServiceTest.php new file mode 100644 index 000000000..fd093de3f --- /dev/null +++ b/_api_app/tests/Feature/SitemapRenderServiceTest.php @@ -0,0 +1,46 @@ + + */ +function sitemapUrls(string $site): array +{ + $sections = [ + ['name' => 'home', 'title' => 'Home', '@attributes' => ['published' => '1']], + ['name' => 'blog', 'title' => 'Blog', '@attributes' => ['published' => '1']], + ['name' => 'draft', 'title' => 'Draft', '@attributes' => ['published' => '0']], + ['name' => 'cart', 'title' => 'Cart', '@attributes' => ['published' => '1', 'type' => 'shopping_cart']], + ]; + $tags = ['section' => [[ + '@attributes' => ['name' => 'blog'], + 'tag' => [['@attributes' => ['name' => 'news']], ['@attributes' => ['name' => 'events']]], + ]]]; + + $xml = (string) (new SitemapRenderService)->render(Request::create('http://shop.test/sitemap.xml'), $site, $sections, $tags); + preg_match_all('#(.*?)#', $xml, $matches); + + return $matches[1]; +} + +it('lists each page at the address the site answers, ending in a slash', function () { + // The section's first tag is the section's own page + expect(sitemapUrls(''))->toBe([ + 'http://shop.test/', + 'http://shop.test/blog/', + 'http://shop.test/blog/events/', + ]); +}); + +it('lists the pages of a multisite site under the site', function () { + expect(sitemapUrls('second'))->toBe([ + 'http://shop.test/second/', + 'http://shop.test/second/blog/', + 'http://shop.test/second/blog/events/', + ]); +}); diff --git a/_api_app/tests/Feature/SitesDataServiceTest.php b/_api_app/tests/Feature/SitesDataServiceTest.php new file mode 100644 index 000000000..f2cbee945 --- /dev/null +++ b/_api_app/tests/Feature/SitesDataServiceTest.php @@ -0,0 +1,87 @@ +bertaRoot = setUpShopTestRoot(); + writeShopSettings($this->bertaRoot, [], 'second'); + File::put($this->bertaRoot . '/storage/-sites/sites.xml', '' + . '<![CDATA[Main]]>' + . '<![CDATA[Second]]>' + . ''); +}); + +afterEach(function () { + if (isset($this->bertaRoot)) { + File::deleteDirectory($this->bertaRoot); + } +}); + +function putShopFile(string $path, string $contents, string $site = ''): void +{ + File::ensureDirectoryExists(dirname(shopFile($path, $site))); + File::put(shopFile($path, $site), $contents); +} + +it('swaps the shop files of two sites along with their contents', function () { + withoutMiddleware(SetupMiddleware::class); + actingAs(new User); + putShopFile('invoices/invoice-2026-1.pdf', '%PDF-main'); + putShopFile('invoices/invoice-2026-1.pdf', '%PDF-second', 'second'); + putShopFile('paypal/EC-1.json', '{}', 'second'); + + putJson(route('site_swap_contents_between_sites'), ['siteSlugFrom' => '', 'siteSlugTo' => 'second'])->assertOk(); + + expect(shopFiles())->toBe(['invoices/invoice-2026-1.pdf', 'paypal/EC-1.json']) + ->and(File::get(shopFile('invoices/invoice-2026-1.pdf')))->toBe('%PDF-second') + ->and(shopFiles('second'))->toBe(['invoices/invoice-2026-1.pdf']) + ->and(File::get(shopFile('invoices/invoice-2026-1.pdf', 'second')))->toBe('%PDF-main'); +}); + +it('starts the order numbers of a site cloned from a site with orders from 1, without its shop files', function () { + travelTo(Carbon::parse('2026-10-14 10:00:00')); + (new ShopOrderNumbersDataService('second'))->next(); + putShopFile('invoices/invoice-2026-1.pdf', '%PDF-second', 'second'); + + $site = (new SitesDataService)->create(Request::create('http://shop.test/'), 'second'); + + expect((new ShopOrderNumbersDataService($site['name']))->next())->toBe('2026-1') + ->and(shopFiles($site['name']))->toBe([]) + ->and((new ShopOrderNumbersDataService('second'))->next())->toBe('2026-2') + ->and(shopFiles('second'))->toBe(['invoices/invoice-2026-1.pdf']); +}); + +it('copies a media folder of a cloned site that shares the shop files folder name', function () { + $mediaFile = $this->bertaRoot . '/storage/-sites/second/media/_shop_files/photo.jpg'; + File::ensureDirectoryExists(dirname($mediaFile)); + File::put($mediaFile, 'jpeg'); + + $site = (new SitesDataService)->create(Request::create('http://shop.test/'), 'second'); + + expect(File::get($this->bertaRoot . "/storage/-sites/{$site['name']}/media/_shop_files/photo.jpg"))->toBe('jpeg'); +}); + +it('leaves the shop files out of the theme preview copy of a site', function () { + symlink(realpath(base_path('../_themes')), $this->bertaRoot . '/_themes'); + // The theme's images are copied into the preview's media folder + File::ensureDirectoryExists($this->bertaRoot . '/storage/media'); + putShopFile('invoices/invoice-2026-1.pdf', '%PDF-main'); + + (new SitesDataService)->createPrieview('kyoto'); + + expect(File::exists($this->bertaRoot . '/storage/preview/settings.xml'))->toBeTrue() + ->and(File::exists($this->bertaRoot . '/storage/preview/_shop_files'))->toBeFalse(); +}); diff --git a/_api_app/tests/Fixtures/BasicValetDriver.php b/_api_app/tests/Fixtures/BasicValetDriver.php new file mode 100644 index 000000000..6aff5fbcc --- /dev/null +++ b/_api_app/tests/Fixtures/BasicValetDriver.php @@ -0,0 +1,17 @@ +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 engine's definitions, and an anonymous request's auth check logs + // out through the legacy engine + foreach (['engine/_classes', 'engine/lang', 'engine/inc.setting-definition.php', 'engine/inc.font-options.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', + // Order emails stay in memory instead of reaching the mail server in .env + 'plugin-Shop.mailer.transport' => 'array', + '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 + * @param array $texts The site's texts, besides its owner's name + */ +function writeShopSettings(string $bertaRoot, array $shop = [], string $site = '', array $texts = []): void +{ + $shop = array_merge([ + 'paymentMethod' => 'both', + 'email' => 'seller@berta.test', + 'currency' => 'EUR', + 'orderEmailSubject' => 'Your order', + 'promoCode' => 'SAVE10', + 'promoCodeDiscount' => '10', + ], $shop); + + $xmlValues = fn (array $values) => implode('', array_map( + fn ($name, $value) => "<{$name}>", + array_keys($values), + $values, + )); + + $siteRoot = $bertaRoot . '/storage' . ($site !== '' ? "/-sites/{$site}" : ''); + File::ensureDirectoryExists($siteRoot); + File::put($siteRoot . '/settings.xml', '' + . '' . $xmlValues($shop) . '' + . '' . $xmlValues(array_merge(['ownerName' => 'Shop Owner'], $texts)) . '' + . ''); +} + +/** + * A file in a site's private shop files, in the test's Berta root + */ +function shopFile(string $path, string $site = ''): string +{ + return config('app.old_berta_root') . '/storage' . ($site !== '' ? "/-sites/{$site}" : '') . "/_shop_files/{$path}"; +} + +/** + * The site's private shop files, relative to their folder + * + * @return list + */ +function shopFiles(string $site = ''): array +{ + $folder = shopFile('', $site); + + return File::isDirectory($folder) + ? array_values(array_map(fn ($file) => $file->getRelativePathname(), File::allFiles($folder))) + : []; +} diff --git a/_api_app/tests/Unit/EngineErrorHandlingTest.php b/_api_app/tests/Unit/EngineErrorHandlingTest.php new file mode 100644 index 000000000..2287ac1d1 --- /dev/null +++ b/_api_app/tests/Unit/EngineErrorHandlingTest.php @@ -0,0 +1,83 @@ +scriptPath = sys_get_temp_dir() . '/berta_error_handling_' . uniqid(); + mkdir($this->scriptPath); +}); + +afterEach(function () { + (new Filesystem)->deleteDirectory($this->scriptPath); +}); + +/** + * Runs a PHP script that includes the engine's error handling between the given pieces of code, with APP_DEBUG + * set to $debug. The engine's logger writes "logger : " to the error output. As Laravel's shutdown + * handler does on a web request, a shutdown function flushes the page buffer while the engine's globals still exist. + */ +function runWithEngineErrorHandling(string $scriptPath, bool $debug, string $before, string $after): Process +{ + $root = dirname(__DIR__, 3); + $script = $scriptPath . '/script.php'; + file_put_contents($script, implode(PHP_EOL, [ + ' ' . var_export($debug, true) . '][$key]; }', + '$logger = new class { public function __call($level, $arguments) { fwrite(STDERR, "logger $level: $arguments[0]"); } };', + 'register_shutdown_function(fn () => ob_end_flush());', + $before, + 'include ' . var_export($root . '/engine/inc.error_handling.php', true) . ';', + $after, + ])); + + $process = new Process([PHP_BINARY, '-d', 'log_errors=0', $script]); + $process->run(); + + return $process; +} + +it('renders the page when an error was suppressed before the engine took over error handling', function () { + // Laravel's .env loader reads a missing file, then Laravel sets an error handler that handles every error + $before = '@file_get_contents(__DIR__ . "/.env"); set_error_handler(fn () => null);'; + + $process = runWithEngineErrorHandling($this->scriptPath, true, $before, 'echo "site content";'); + + expect($process->getOutput())->toBe('site content'); +}); + +it('replaces the page with the error report on a fatal error with APP_DEBUG', function () { + $process = runWithEngineErrorHandling($this->scriptPath, true, '', 'echo "site content"; undefined_function();'); + + expect($process->getOutput()) + ->toContain('Error: Uncaught Error: Call to undefined function undefined_function()') + ->not->toContain('site content'); +}); + +it('replaces the page with a generic message and logs the fatal error without APP_DEBUG', function () { + $process = runWithEngineErrorHandling($this->scriptPath, false, '', 'echo "site content"; undefined_function();'); + + expect($process->getOutput())->toBe('Server error'); + expect($process->getErrorOutput())->toContain('logger error: Error - Uncaught Error: Call to undefined function undefined_function()'); +}); + +it('shows a warning in the page with APP_DEBUG', function () { + $process = runWithEngineErrorHandling($this->scriptPath, true, '', 'echo $undefined; echo "site content";'); + + expect($process->getOutput()) + ->toContain('Warning: Undefined variable $undefined') + ->toEndWith('site content'); +}); + +it('logs a warning without showing it without APP_DEBUG', function () { + $process = runWithEngineErrorHandling($this->scriptPath, false, '', 'echo $undefined; echo "site content";'); + + expect($process->getOutput())->toBe('site content'); + expect($process->getErrorOutput())->toContain('logger warning: Warning - Undefined variable $undefined'); +}); diff --git a/_api_app/tests/Unit/HelpersTest.php b/_api_app/tests/Unit/HelpersTest.php index 34bf2557a..d49e1ebaa 100644 --- a/_api_app/tests/Unit/HelpersTest.php +++ b/_api_app/tests/Unit/HelpersTest.php @@ -8,6 +8,16 @@ expect($result)->toBe('hello-world'); }); +it('gives a page its address ending in a slash', function (array $urlParts, bool $isPreviewMode, string $path) { + expect(Helpers::sitePath($urlParts, $isPreviewMode))->toBe($path); +})->with([ + 'the front page' => [[], false, '/'], + 'a section' => [['section' => 'about'], false, '/about/'], + 'a tag of a multisite section' => [['site' => 'second', 'section' => 'blog', 'tag' => 'news'], false, '/second/blog/news/'], + 'a preview' => [['section' => 'about'], true, '/about/?preview=1'], + 'the front page preview' => [[], true, '/?preview=1'], +]); + it('converts an array to html attributes string', function () { $array = [ 'class' => 'btn btn-primary', diff --git a/_api_app/tests/Unit/WebAccessRulesTest.php b/_api_app/tests/Unit/WebAccessRulesTest.php new file mode 100644 index 000000000..8167663bb --- /dev/null +++ b/_api_app/tests/Unit/WebAccessRulesTest.php @@ -0,0 +1,141 @@ +sitePath = sys_get_temp_dir() . '/berta_web_rules_' . uniqid(); +}); + +afterEach(function () { + (new Filesystem)->deleteDirectory($this->sitePath); +}); + +function bertaRepositoryRoot(): string +{ + return dirname(__DIR__, 3); +} + +/** + * Whether the root .htaccess answers a request with its 404 rule, the first rule a /storage request meets + */ +function htaccessRefuses(string $uri): bool +{ + $lines = array_map('trim', file(bertaRepositoryRoot() . '/.htaccess', FILE_IGNORE_NEW_LINES)); + $ruleLine = array_search('RewriteRule ^ - [R=404,L]', $lines, true); + + // The rule's conditions are the RewriteCond lines right above it, any of them refuses the request + $patterns = []; + for ($i = $ruleLine - 1; $i >= 0 && preg_match('/^RewriteCond %\{REQUEST_URI\} (\S+)/', $lines[$i], $match); $i--) { + $patterns[] = $match[1]; + } + + if ($ruleLine === false || $patterns === []) { + throw new RuntimeException('The 404 rule of .htaccess was not found'); + } + + foreach ($patterns as $pattern) { + if (preg_match('#' . str_replace('#', '\#', $pattern) . '#', $uri)) { + return true; + } + } + + return false; +} + +/** + * Whether the root .htaccess redirects a request for no file to its address with a trailing slash + */ +function htaccessAddsSlash(string $uri): bool +{ + $lines = array_map('trim', file(bertaRepositoryRoot() . '/.htaccess', FILE_IGNORE_NEW_LINES)); + $ruleLine = array_search('RewriteRule ^(.*)$ /$1/ [R=301,L]', $lines, true); + + // The rule's address conditions are the negated RewriteCond lines right above it, each of them must pass + $patterns = []; + for ($i = $ruleLine - 1; $i >= 0 && preg_match('/^RewriteCond %\{REQUEST_URI\} !(\S+)/', $lines[$i], $match); $i--) { + $patterns[] = $match[1]; + } + + if ($ruleLine === false || $patterns === []) { + throw new RuntimeException('The trailing slash rule of .htaccess was not found'); + } + + foreach ($patterns as $pattern) { + if (preg_match('#' . str_replace('#', '\#', $pattern) . '#', $uri)) { + return false; + } + } + + return true; +} + +/** + * How LocalValetDriver routes a request: "not_found", "static" or another of its route types + */ +function valetDriverRoute(string $sitePath, string $uri): string +{ + if (! class_exists(BasicValetDriver::class)) { + require __DIR__ . '/../Fixtures/BasicValetDriver.php'; + } + require_once bertaRepositoryRoot() . '/LocalValetDriver.php'; + + $resolve = Closure::bind(fn (string $uri) => $this->resolve($sitePath, $uri), new LocalValetDriver, LocalValetDriver::class); + + return $resolve($uri)[0]; +} + +/** + * A file at the request's path, as the driver only serves files that exist + */ +function putWebFile(string $sitePath, string $uri): void +{ + (new Filesystem)->ensureDirectoryExists(dirname($sitePath . $uri)); + file_put_contents($sitePath . $uri, 'contents'); +} + +it('refuses the shop files of the root site and of a multisite site', function (string $uri) { + putWebFile($this->sitePath, $uri); + + expect(htaccessRefuses($uri))->toBeTrue() + ->and(valetDriverRoute($this->sitePath, $uri))->toBe('not_found'); +})->with([ + 'the root site invoice' => '/storage/_shop_files/invoices/invoice-2026-1.pdf', + 'the root site payment record' => '/storage/_shop_files/paypal/5O190127TN364715T.json', + 'a multisite site invoice' => '/storage/-sites/second/_shop_files/invoices/invoice-2026-1.pdf', + 'a multisite site payment record' => '/storage/-sites/second/_shop_files/paypal/5O190127TN364715T.json', +]); + +it('serves media, also in folders named like the shop files folder', function (string $uri) { + putWebFile($this->sitePath, $uri); + + expect(htaccessRefuses($uri))->toBeFalse() + ->and(valetDriverRoute($this->sitePath, $uri))->toBe('static'); +})->with([ + 'root site media' => '/storage/media/photo.jpg', + 'multisite site media' => '/storage/-sites/second/media/photo.jpg', + 'a media folder sharing the name' => '/storage/media/_shop_files/photo.jpg', + 'a site named like the folder' => '/storage/-sites/_shop_files/media/photo.jpg', +]); + +it('redirects a page to its address with the trailing slash', function (string $uri) { + expect(htaccessAddsSlash($uri))->toBeTrue() + ->and(valetDriverRoute($this->sitePath, $uri))->toBe('redirect'); +})->with([ + 'a section' => '/shop', + 'a multisite section' => '/second/shop', +]); + +it('serves the sitemap at its own address', function (string $uri) { + expect(htaccessAddsSlash($uri))->toBeFalse() + ->and(valetDriverRoute($this->sitePath, $uri))->toBe('php'); +})->with([ + 'the root site' => '/sitemap.xml', + 'a multisite site' => '/second/sitemap.xml', +]); diff --git a/_templates/default/template.conf.php b/_templates/default/template.conf.php index 5778ae003..95e5b37d7 100644 --- a/_templates/default/template.conf.php +++ b/_templates/default/template.conf.php @@ -1,1441 +1,1431 @@ 'Helvetica, Arial, sans-serif', - '"Helvetica Neue", Helvetica, Arial, sans-serif' => 'Helvetica Neue, Helvetica, Arial, sans-serif', - '"Arial Black", Gadget, sans-serif' => 'Arial Black, Gadget', - '"Comic Sans MS", cursive' => 'Comic Sans MS', - '"Courier New", Courier, monospace' => 'Courier New, Courier', - 'Georgia, "Times New Roman", Times, serif' => 'Georgia, Times New Roman, Times', - 'Impact, Charcoal, sans-serif' => 'Impact, Charcoal', - '"Lucida Console", Monaco, monospace' => 'Lucida Console, Monaco', - '"Lucida Sans Unicode", "Lucida Grande", sans-serif' => 'Lucida Sans Unicode, Lucida Grande', - '"Palatino Linotype", "Book Antiqua", Palatino, serif' => 'Palatino Linotype, Book Antiqua, Palatino', - 'Tahoma, Geneva, sans-serif' => 'Tahoma, Geneva', - '"Times New Roman", Times, serif' => 'Times New Roman, Times', - '"Trebuchet MS", Helvetica, sans-serif' => 'Trebuchet MS, Helvetica', - 'Verdana, Geneva, sans-serif' => 'Verdana, Geneva', -]; -$fontOptionsWithInherit = array_merge([ - 'inherit' => '(inherit from general-font-settings)', -], $fontOptions); +/** + * The template's section types and settings: their default values for the site engine, and their titles and + * descriptions for the editor. `$translate` translates a title or description into the editor's language. + * + * @return Closure(callable(string): string, bool): array{0: array, 1: array, 2: array} + */ +return static function (callable $translate, bool $isShopEnabled): array { + ['fonts' => $fontOptions, 'withInherit' => $fontOptionsWithInherit] = require dirname(__DIR__, 2) . '/engine/inc.font-options.php'; -$sectionTypes = [ - 'default' => [ - 'title' => 'Default', - ], - 'external_link' => [ - 'title' => 'External link', - 'params' => [ - 'link' => [ - 'format' => 'url', - 'default' => '', - 'allow_blank' => true, - 'link' => true, - ], - 'target' => [ - 'format' => 'select', - 'values' => [ - '_self' => 'Same window', - '_blank' => 'New window', - ], - 'default' => '_blank', - ], - ], - ], - 'portfolio' => [ - 'title' => 'Portfolio', - ], -]; - -$fontOptions = array_keys($fontOptions); - -$templateConf = [ - 'generalFontSettings' => [ - '_' => [ - 'title' => I18n::_('General font settings'), + $sectionTypes = [ + 'default' => [ + 'title' => 'Default', ], - 'color' => [ - 'format' => 'color', - 'default' => '#333333', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'color', + 'external_link' => [ + 'title' => 'External link', + 'params' => [ + 'link' => [ + 'format' => 'url', + 'default' => '', + 'allow_blank' => true, + 'link' => true, ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptions, - 'default' => reset($fontOptions), - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', + 'target' => [ + 'format' => 'select', + 'values' => [ + '_self' => 'Same window', + '_blank' => 'New window', + ], + 'default' => '_blank', ], ], ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', - ], - ], + 'portfolio' => [ + 'title' => 'Portfolio', ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '9pt', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-size', - ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-weight', - ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-style', - ], - ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-variant', - ], - ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'normal', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'line-height', - ], - ], - ], - ], + ]; - 'links' => [ - '_' => [ - 'title' => I18n::_('Hyperlinks'), - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'color', - ], - ], - ], - 'colorVisited' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Visited link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'color', - ], - ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Link color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'color', + $fontOptions = array_keys($fontOptions); + + $templateConf = [ + 'generalFontSettings' => [ + '_' => [ + 'title' => $translate('General font settings'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#333333', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptions, + 'default' => 'Helvetica, Arial, sans-serif', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '9pt', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-size', + ], ], ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Link color when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'color', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-weight', + ], ], ], - ], - 'textDecorationVisited' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Visited link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'text-decoration', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-style', + ], ], ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'text-decoration', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'normal', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'line-height', + ], ], ], ], - ], - 'background' => [ - '_' => [ - 'title' => I18n::_('Background'), - ], - 'backgroundColor' => [ - 'format' => 'color', - 'default' => '#FFFFFF', - 'title' => I18n::_('Background color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-color', - ], - ], - ], - 'backgroundImageEnabled' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Is background image enabled?'), - 'description' => '', - ], - 'backgroundImage' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'affectsStyle' => true, - 'title' => I18n::_('Background image'), - 'description' => I18n::_('Picture to use for page background.'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-image', - ], - ], - ], - 'backgroundRepeat' => [ - 'format' => 'select', - 'values' => [ - 'repeat' => 'tile vertically and horizontally', - 'repeat-x' => 'tile horizontally', - 'repeat-y' => 'tile vertically', - 'no-repeat' => 'no tiling', - ], - 'default' => 'repeat', - 'title' => I18n::_('Background tiling'), - 'description' => I18n::_('How the background fills the screen?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-repeat', + 'links' => [ + '_' => [ + 'title' => $translate('Hyperlinks'), + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'color', + ], + ], + ], + 'colorVisited' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Visited link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Link color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Link color when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationVisited' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Visited link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Link decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', ], - ], - ], - 'backgroundPosition' => [ - 'format' => 'select', - 'values' => [ - 'top left', - 'top center', - 'top right', - 'center left', - 'center', - 'center right', - 'bottom left', - 'bottom center', - 'bottom right', - ], - 'default' => 'top left', - 'title' => I18n::_('Background alignment'), - 'description' => I18n::_('Where the background image is positioned?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-position', + 'default' => 'underline', + 'title' => $translate('Link decoration when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'text-decoration', + ], ], ], ], - 'backgroundAttachment' => [ - 'format' => 'select', - 'values' => [ - 'fixed' => 'Fixed to browser window', - 'fill' => 'Filled in browser window', - 'scroll' => 'No stretch, scroll along with content', - ], - 'default' => 'scroll', - 'title' => I18n::_('Background position'), - 'description' => I18n::_('Sets how background behaves in relation with the browser window.'), - ], - ], - 'pageLayout' => [ - '_' => [ - 'title' => I18n::_('Page layout'), - ], - 'contentPosition' => [ - 'format' => 'select', - 'values' => [ - 'left', - 'center', - 'right', - ], - 'default' => 'left', - 'title' => I18n::_('Content position'), - 'description' => '', - ], - 'contentAlign' => [ - 'format' => 'select', - 'values' => [ - 'left', - 'right', - 'justify-left', - 'justify-right', - ], - 'default' => 'left', - 'title' => I18n::_('Text alignment'), - 'description' => '', - ], - 'contentWidth' => [ - 'format' => 'text', - 'default' => '500px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Width of content area'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer', - 'property' => 'width', - ], - [ - 'selector' => '.bt-responsive #contentContainer', - 'property' => 'max-width', - ], - ], - ], - 'bodyMargin' => [ - 'format' => 'text', - 'default' => '20px 40px 40px', - 'allow_blank' => true, - 'title' => I18n::_('Page margins'), - 'description' => I18n::_('How far the content is from browser edges.'), - 'css' => [ - [ - 'selector' => '#contentContainer', - 'property' => 'padding', + 'background' => [ + '_' => [ + 'title' => $translate('Background'), + ], + 'backgroundColor' => [ + 'format' => 'color', + 'default' => '#FFFFFF', + 'title' => $translate('Background color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-color', + ], + ], + ], + 'backgroundImageEnabled' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Is background image enabled?'), + 'description' => '', ], - ], - 'siteMenuMargin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'title' => I18n::_('Top menu margins'), - 'description' => I18n::_('How big is the distance from the top menu to the other page elements'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu', - 'property' => 'padding', + 'backgroundImage' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'affectsStyle' => true, + 'title' => $translate('Background image'), + 'description' => $translate('Picture to use for page background.'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-image', + ], + ], + ], + 'backgroundRepeat' => [ + 'format' => 'select', + 'values' => [ + 'repeat' => 'tile vertically and horizontally', + 'repeat-x' => 'tile horizontally', + 'repeat-y' => 'tile vertically', + 'no-repeat' => 'no tiling', + ], + 'default' => 'repeat', + 'title' => $translate('Background tiling'), + 'description' => $translate('How the background fills the screen?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-repeat', + ], + ], + ], + 'backgroundPosition' => [ + 'format' => 'select', + 'values' => [ + 'top left', + 'top center', + 'top right', + 'center left', + 'center', + 'center right', + 'bottom left', + 'bottom center', + 'bottom right', + ], + 'default' => 'top left', + 'title' => $translate('Background alignment'), + 'description' => $translate('Where the background image is positioned?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-position', + ], + ], + ], + 'backgroundAttachment' => [ + 'format' => 'select', + 'values' => [ + 'fixed' => 'Fixed to browser window', + 'fill' => 'Filled in browser window', + 'scroll' => 'No stretch, scroll along with content', ], + 'default' => 'scroll', + 'title' => $translate('Background position'), + 'description' => $translate('Sets how background behaves in relation with the browser window.'), ], ], - 'group_responsive' => [ - 'format' => false, - 'default' => false, - 'title' => I18n::_('Responsive design'), - ], - 'responsive' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'no', - 'yes', - ], - 'title' => I18n::_('Enabled'), - 'description' => I18n::_('Sets whether layout should be responsive or not.'), - ], - ], - 'pageHeading' => [ - '_' => [ - 'title' => I18n::_('Page heading'), - ], - 'image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 1000, - 'max_height' => 1000, - 'title' => I18n::_('Header image'), - 'description' => '' . I18n::_('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', - ], - 'color' => [ - 'format' => 'color', - 'default' => '#333333', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'color', - ], - [ - 'selector' => '#contentContainer h1 a', - 'property' => 'color', - ], - [ - 'selector' => '#contentContainer h1 a:link', - 'property' => 'color', - ], - [ - 'selector' => '#contentContainer h1 a:visited', - 'property' => 'color', - ], - [ - 'selector' => '#contentContainer h1 a:hover', - 'property' => 'color', - ], - [ - 'selector' => '#contentContainer h1 a:active', - 'property' => 'color', - ], + 'pageLayout' => [ + '_' => [ + 'title' => $translate('Page layout'), ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '1.2em', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'font-size', + 'contentPosition' => [ + 'format' => 'select', + 'values' => [ + 'left', + 'center', + 'right', ], + 'default' => 'left', + 'title' => $translate('Content position'), + 'description' => '', ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'bold', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'font-weight', + 'contentAlign' => [ + 'format' => 'select', + 'values' => [ + 'left', + 'right', + 'justify-left', + 'justify-right', + ], + 'default' => 'left', + 'title' => $translate('Text alignment'), + 'description' => '', + ], + 'contentWidth' => [ + 'format' => 'text', + 'default' => '500px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Width of content area'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer', + 'property' => 'width', + ], + [ + 'selector' => '.bt-responsive #contentContainer', + 'property' => 'max-width', + ], + ], + ], + 'bodyMargin' => [ + 'format' => 'text', + 'default' => '20px 40px 40px', + 'allow_blank' => true, + 'title' => $translate('Page margins'), + 'description' => $translate('How far the content is from browser edges.'), + 'css' => [ + [ + 'selector' => '#contentContainer', + 'property' => 'padding', + ], + ], + ], + 'siteMenuMargin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'title' => $translate('Top menu margins'), + 'description' => $translate('How big is the distance from the top menu to the other page elements'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu', + 'property' => 'padding', + ], ], ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', + 'group_responsive' => [ + 'format' => false, + 'default' => false, + 'title' => $translate('Responsive design'), ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'font-style', + 'responsive' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'no', + 'yes', ], + 'title' => $translate('Enabled'), + 'description' => $translate('Sets whether layout should be responsive or not.'), ], ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', + + 'pageHeading' => [ + '_' => [ + 'title' => $translate('Page heading'), ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'font-variant', + 'image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 1000, + 'max_height' => 1000, + 'title' => $translate('Header image'), + 'description' => '' . $translate('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', + ], + 'color' => [ + 'format' => 'color', + 'default' => '#333333', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'color', + ], + [ + 'selector' => '#contentContainer h1 a', + 'property' => 'color', + ], + [ + 'selector' => '#contentContainer h1 a:link', + 'property' => 'color', + ], + [ + 'selector' => '#contentContainer h1 a:visited', + 'property' => 'color', + ], + [ + 'selector' => '#contentContainer h1 a:hover', + 'property' => 'color', + ], + [ + 'selector' => '#contentContainer h1 a:active', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '1.2em', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'font-size', + ], ], ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '0.8em', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'line-height', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'margin' => [ - 'format' => 'text', - 'default' => '10px 0 15px', - 'allow_blank' => true, - 'title' => I18n::_('Margins'), - 'description' => I18n::_('How far the heading is from other elements in page.'), - 'css' => [ - [ - 'selector' => 'h1', - 'property' => 'margin', + 'default' => 'bold', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'font-weight', + ], ], ], - ], - ], - - 'additionalText' => [ - '_' => [ - 'title' => I18n::_('Additional text'), - ], - 'color' => [ - 'format' => 'color', - 'default' => 'inherit', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'color', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'font-style', + ], ], ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => '0.8em', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'line-height', + ], + ], + ], + 'margin' => [ + 'format' => 'text', + 'default' => '10px 0 15px', + 'allow_blank' => true, + 'title' => $translate('Margins'), + 'description' => $translate('How far the heading is from other elements in page.'), + 'css' => [ + [ + 'selector' => 'h1', + 'property' => 'margin', + ], ], ], ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-size', + + 'additionalText' => [ + '_' => [ + 'title' => $translate('Additional text'), + ], + 'color' => [ + 'format' => 'color', + 'default' => 'inherit', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-size', + ], ], ], ], - ], - 'menu' => [ - '_' => [ - 'title' => I18n::_('Main menu'), - ], - 'separator' => [ - 'format' => 'text', - 'default' => '|', - 'allow_blank' => true, - 'title' => I18n::_('Menu items separator'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li:not(:first-child)::before', - 'property' => 'content', - 'template' => '`"${value}"`', - ], + 'menu' => [ + '_' => [ + 'title' => $translate('Main menu'), ], - ], - 'separatorDistance' => [ - 'format' => 'text', - 'css_units' => true, - 'default' => '0.5em', - 'allow_blank' => true, - 'title' => I18n::_('Space width around separator'), - 'description' => I18n::_('The distance from the separator to the menu item on both sides'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li:not(:first-child)::before', - 'property' => 'padding-left', - ], - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li:not(:first-child)::before', - 'property' => 'padding-right', + 'separator' => [ + 'format' => 'text', + 'default' => '|', + 'allow_blank' => true, + 'title' => $translate('Menu items separator'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li:not(:first-child)::before', + 'property' => 'content', + 'template' => '`"${value}"`', + ], + ], + ], + 'separatorDistance' => [ + 'format' => 'text', + 'css_units' => true, + 'default' => '0.5em', + 'allow_blank' => true, + 'title' => $translate('Space width around separator'), + 'description' => $translate('The distance from the separator to the menu item on both sides'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li:not(:first-child)::before', + 'property' => 'padding-left', + ], + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li:not(:first-child)::before', + 'property' => 'padding-right', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'font-size', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'font-family', + ], + ], + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu ul li a:link', + 'property' => 'color', + ], + [ + 'selector' => '.bt-sections-menu ul li a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Color when hovered'), + 'description' => $translate('Color of the element under mouse cursor'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu ul li a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#888888', + 'title' => $translate('Color when selected'), + 'description' => $translate('Color of the element of the currently opened section'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu ul li a:active', + 'property' => 'color', + ], + [ + 'selector' => '.bt-sections-menu ul li.selected > a', + 'property' => 'color', + ], + ], + ], + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'bold', ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'font-size', + 'default' => 'inherit', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'font-weight', + ], ], ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'font-family', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'italic', ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'font-family', + 'default' => 'inherit', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'font-style', + ], ], ], - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu ul li a:link', - 'property' => 'color', - ], - [ - 'selector' => '.bt-sections-menu ul li a:visited', - 'property' => 'color', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'small-caps', + ], + 'default' => 'inherit', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', + 'property' => 'line-height', + ], + ], + ], + 'margin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'title' => $translate('Margins'), + 'description' => $translate('How far the menu is form other elements in page.'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul:not(.subMenu)', + 'property' => 'padding', + ], ], ], ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Color when hovered'), - 'description' => I18n::_('Color of the element under mouse cursor'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu ul li a:hover', - 'property' => 'color', - ], + + 'subMenu' => [ + '_' => [ + 'title' => $translate('Submenu'), ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#888888', - 'title' => I18n::_('Color when selected'), - 'description' => I18n::_('Color of the element of the currently opened section'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu ul li a:active', - 'property' => 'color', - ], - [ - 'selector' => '.bt-sections-menu ul li.selected > a', - 'property' => 'color', + 'separator' => [ + 'format' => 'text', + 'default' => '|', + 'allow_blank' => true, + 'title' => $translate('Menu items separator'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li:not(:first-child)::before', + 'property' => 'content', + 'template' => '`"${value}"`', + ], + ], + ], + 'separatorDistance' => [ + 'format' => 'text', + 'css_units' => true, + 'default' => '0.5em', + 'allow_blank' => true, + 'title' => $translate('Space width around separator'), + 'description' => $translate('The distance from the separator to the menu item on both sides'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li:not(:first-child)::before', + 'property' => 'padding-left', + ], + [ + 'selector' => '.bt-sections-menu > .subMenu li:not(:first-child)::before', + 'property' => 'padding-right', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'font-size', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'font-family', + ], ], ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'bold', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'font-weight', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'bold', ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'italic', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'font-style', + 'default' => 'inherit', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'font-weight', + ], ], ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'small-caps', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'font-variant', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'italic', ], - ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu) li', - 'property' => 'line-height', + 'default' => 'inherit', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'font-style', + ], ], ], - ], - 'margin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'title' => I18n::_('Margins'), - 'description' => I18n::_('How far the menu is form other elements in page.'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul:not(.subMenu)', - 'property' => 'padding', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'small-caps', + ], + 'default' => 'inherit', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu li', + 'property' => 'line-height', + ], + ], + ], + 'margin' => [ + 'format' => 'text', + 'default' => '0 0 10px', + 'allow_blank' => true, + 'title' => $translate('Margins'), + 'description' => $translate('How far the menu is form other elements in page.'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > .subMenu', + 'property' => 'padding', + ], ], ], ], - ], - 'subMenu' => [ - '_' => [ - 'title' => I18n::_('Submenu'), - ], - 'separator' => [ - 'format' => 'text', - 'default' => '|', - 'allow_blank' => true, - 'title' => I18n::_('Menu items separator'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li:not(:first-child)::before', - 'property' => 'content', - 'template' => '`"${value}"`', - ], + 'entryLayout' => [ + '_' => [ + 'title' => $translate('Entry layout'), ], - ], - 'separatorDistance' => [ - 'format' => 'text', - 'css_units' => true, - 'default' => '0.5em', - 'allow_blank' => true, - 'title' => I18n::_('Space width around separator'), - 'description' => I18n::_('The distance from the separator to the menu item on both sides'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li:not(:first-child)::before', - 'property' => 'padding-left', - ], - [ - 'selector' => '.bt-sections-menu > .subMenu li:not(:first-child)::before', - 'property' => 'padding-right', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'font-size', - ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'font-family', - ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'bold', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'font-weight', - ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'italic', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'font-style', + 'margin' => [ + 'format' => 'text', + 'default' => '0 0 4em', + 'allow_blank' => true, + 'title' => $translate('Entry margins'), + 'description' => $translate('Margins around entries.'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry', + 'property' => 'margin', + ], ], ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'small-caps', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'font-variant', + 'galleryPosition' => [ + 'format' => 'select', + 'values' => [ + 'between title/description', + 'above title', + 'below description', ], + 'default' => 'between title/description', + 'title' => $translate('Gallery position'), + 'description' => '', ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu li', - 'property' => 'line-height', + 'defaultGalleryType' => [ + 'format' => 'select', + 'values' => [ + 'slideshow', + 'row', + 'grid', ], + 'default' => 'slideshow', + 'title' => $translate('Default gallery type'), + 'description' => $translate('Slideshow means that an image menu plus only one image is visible at a time. Row means that all images are visible.'), ], - ], - 'margin' => [ - 'format' => 'text', - 'default' => '0 0 10px', - 'allow_blank' => true, - 'title' => I18n::_('Margins'), - 'description' => I18n::_('How far the menu is form other elements in page.'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > .subMenu', - 'property' => 'padding', + 'spaceBetweenImages' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images in row and column'), + 'description' => $translate('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .xGalleryType-column .xGalleryItem', + 'property' => 'padding-bottom', + ], + [ + 'selector' => '#pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', + 'property' => 'margin-right', + ], + [ + 'selector' => '.bt-responsive #pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', + 'property' => 'padding-bottom', + 'breakpoint' => '(max-width: 767px)', + ], + ], + ], + 'galleryMargin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'title' => $translate('Gallery margins'), + 'description' => $translate('Margin around gallery block'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .xGalleryContainer .xGallery', + 'property' => 'margin', + ], ], ], ], - ], - 'entryLayout' => [ - '_' => [ - 'title' => I18n::_('Entry layout'), - ], - 'margin' => [ - 'format' => 'text', - 'default' => '0 0 4em', - 'allow_blank' => true, - 'title' => I18n::_('Entry margins'), - 'description' => I18n::_('Margins around entries.'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry', - 'property' => 'margin', + 'entryHeading' => [ + '_' => [ + 'title' => $translate('Entry heading'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#333333', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '1.8em', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'font-size', + ], ], ], - ], - 'galleryPosition' => [ - 'format' => 'select', - 'values' => [ - 'between title/description', - 'above title', - 'below description', - ], - 'default' => 'between title/description', - 'title' => I18n::_('Gallery position'), - 'description' => '', - ], - 'defaultGalleryType' => [ - 'format' => 'select', - 'values' => [ - 'slideshow', - 'row', - 'grid', - ], - 'default' => 'slideshow', - 'title' => I18n::_('Default gallery type'), - 'description' => I18n::_('Slideshow means that an image menu plus only one image is visible at a time. Row means that all images are visible.'), - ], - 'spaceBetweenImages' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images in row and column'), - 'description' => I18n::_('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .xGalleryType-column .xGalleryItem', - 'property' => 'padding-bottom', - ], - [ - 'selector' => '#pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', - 'property' => 'margin-right', - ], - [ - 'selector' => '.bt-responsive #pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', - 'property' => 'padding-bottom', - 'breakpoint' => '(max-width: 767px)', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'galleryMargin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'title' => I18n::_('Gallery margins'), - 'description' => I18n::_('Margin around gallery block'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .xGalleryContainer .xGallery', - 'property' => 'margin', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'font-weight', + ], ], ], - ], - ], - - 'entryHeading' => [ - '_' => [ - 'title' => I18n::_('Entry heading'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#333333', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'color', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'font-family', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'font-style', + ], ], ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'font-family', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'normal', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'line-height', + ], + ], + ], + 'margin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'title' => $translate('Margins'), + 'description' => $translate('How far the entry heading is form other elements in page.'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry h2', + 'property' => 'margin', + ], ], ], ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '1.8em', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'font-size', + + 'entryFooter' => [ + '_' => [ + 'title' => $translate('Entry footer'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#333333', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'font-size', + ], ], ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'font-weight', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'bold', ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'font-style', + 'default' => 'inherit', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'font-weight', + ], ], ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'font-variant', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'italic', ], - ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'normal', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'line-height', + 'default' => 'inherit', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'font-style', + ], ], ], - ], - 'margin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'title' => I18n::_('Margins'), - 'description' => I18n::_('How far the entry heading is form other elements in page.'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry h2', - 'property' => 'margin', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'inherit', + 'normal', + 'small-caps', + ], + 'default' => 'inherit', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .entryContent', + 'property' => 'line-height', + ], ], ], ], - ], - 'entryFooter' => [ - '_' => [ - 'title' => I18n::_('Entry footer'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#333333', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'color', - ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'font-size', - ], + 'socialMediaLinks' => [ + '_' => [ + 'title' => $translate('Social media buttons'), ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'bold', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'font-weight', + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Button color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.social-icon path', + 'property' => 'fill', + ], ], ], ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'italic', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'font-style', - ], - ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'inherit', - 'normal', - 'small-caps', - ], - 'default' => 'inherit', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'font-variant', - ], + + 'css' => [ + '_' => [ + 'title' => $translate('Custom CSS'), ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .entryContent', - 'property' => 'line-height', - ], + 'customCSS' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Custom CSS'), + 'description' => $translate('custom_css_description'), ], ], - ], - 'socialMediaLinks' => [ - '_' => [ - 'title' => I18n::_('Social media buttons'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Button color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.social-icon path', - 'property' => 'fill', - ], + 'settings' => [ + '_' => [ + 'title' => $translate('Other settings'), + ], + 'resetTemplate' => [ + 'format' => 'action', + 'default' => 'resetTemplate', + 'title' => $translate('Reset design'), + 'description' => $translate('Reset design to default values.'), ], ], - ], - - 'css' => [ - '_' => [ - 'title' => I18n::_('Custom CSS'), - ], - 'customCSS' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Custom CSS'), - 'description' => I18n::_('description_custom_css'), - ], - ], + ]; - 'settings' => [ - '_' => [ - 'title' => I18n::_('Other settings'), + $translations = [ + 'sectionTypes' => [ + 'type' => $translate('section_type'), + 'type_tip' => $translate('section_type_tip'), ], - 'resetTemplate' => [ - 'format' => 'action', - 'default' => 'resetTemplate', - 'title' => I18n::_('Reset design'), - 'description' => I18n::_('Reset design to default values.'), - ], - ], -]; - -$translations = [ - 'sectionTypes' => [ - 'type' => I18n::_('section_type'), - 'type_tip' => I18n::_('section_type_tip'), - ], -]; + ]; -return [$sectionTypes, $templateConf, $translations]; + return [$sectionTypes, $templateConf, $translations]; +}; diff --git a/_templates/mashup-0.3.5/template.conf.php b/_templates/mashup-0.3.5/template.conf.php index b694c1f6d..fcca863a7 100644 --- a/_templates/mashup-0.3.5/template.conf.php +++ b/_templates/mashup-0.3.5/template.conf.php @@ -1,1395 +1,1384 @@ 'Arial, sans-serif', - 'Helvetica, Arial, sans-serif' => 'Helvetica, Arial, sans-serif', - '"Helvetica Neue", Helvetica, Arial, sans-serif' => 'Helvetica Neue, Helvetica, Arial, sans-serif', - '"Arial Black", Gadget, sans-serif' => 'Arial Black, Gadget', - '"Comic Sans MS", cursive' => 'Comic Sans MS', - '"Courier New", Courier, monospace' => 'Courier New, Courier', - 'Georgia, "Times New Roman", Times, serif' => 'Georgia, Times New Roman, Times', - 'Impact, Charcoal, sans-serif' => 'Impact, Charcoal', - '"Lucida Console", Monaco, monospace' => 'Lucida Console, Monaco', - '"Lucida Sans Unicode", "Lucida Grande", sans-serif' => 'Lucida Sans Unicode, Lucida Grande', - '"Palatino Linotype", "Book Antiqua", Palatino, serif' => 'Palatino Linotype, Book Antiqua, Palatino', - 'Tahoma, Geneva, sans-serif' => 'Tahoma, Geneva', - '"Times New Roman", Times, serif' => 'Times New Roman, Times', - '"Trebuchet MS", Helvetica, sans-serif' => 'Trebuchet MS, Helvetica', - 'Verdana, Geneva, sans-serif' => 'Verdana, Geneva', -]; -$fontOptionsWithInherit = array_merge([ - 'inherit' => '(inherit from general-font-settings)', -], $fontOptions); +/** + * The template's section types and settings: their default values for the site engine, and their titles and + * descriptions for the editor. `$translate` translates a title or description into the editor's language. + * + * @return Closure(callable(string): string, bool): array{0: array, 1: array, 2: array} + */ +return static function (callable $translate, bool $isShopEnabled): array { + ['fonts' => $fontOptions, 'withInherit' => $fontOptionsWithInherit] = require dirname(__DIR__, 2) . '/engine/inc.font-options.php'; -$sectionTypes = [ - 'default' => [ - 'title' => 'Default', - ], - 'external_link' => [ - 'title' => 'External link', - 'params' => [ - 'link' => [ - 'format' => 'url', + $sectionTypes = [ + 'default' => [ + 'title' => 'Default', + ], + 'external_link' => [ + 'title' => 'External link', + 'params' => [ + 'link' => [ + 'format' => 'url', + 'default' => '', + 'allow_blank' => true, + 'link' => true, + ], + 'target' => [ + 'format' => 'select', + 'values' => [ + '_self' => 'Same window', + '_blank' => 'New window', + ], + 'default' => '_blank', + ], + ], + ], + 'mash_up' => [ + 'title' => 'Mash-up', + 'params' => [ + 'marked_items_imageselect' => [ + 'format' => 'select', + 'values' => [ + 'random' => $translate('select_on'), + 'first' => $translate('select_off'), + ], + 'default' => 'first', + 'html_before' => $translate('shuffle_marked'), + ], + 'marked_items_count' => [ + 'format' => 'text', + 'html_before' => $translate('entries_limit'), + 'default' => '5', + 'validation' => 'positive_integer', + 'tip' => $translate('entries_limit_tip'), + ], + ], + ], + 'portfolio' => [ + 'title' => 'Portfolio', + ], + ]; + + $templateConf = [ + 'generalFontSettings' => [ + '_' => [ + 'title' => $translate('General font settings'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#1a1a1a', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptions, + 'default' => '"Times New Roman", Times, serif', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', 'default' => '', 'allow_blank' => true, - 'link' => true, - ], - 'target' => [ - 'format' => 'select', - 'values' => [ - '_self' => 'Same window', - '_blank' => 'New window', - ], - 'default' => '_blank', - ], - ], - ], - 'mash_up' => [ - 'title' => 'Mash-up', - 'params' => [ - 'marked_items_imageselect' => [ - 'format' => 'select', - 'values' => [ - 'random' => I18n::_('select_on'), - 'first' => I18n::_('select_off'), + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], ], - 'default' => 'first', - 'html_before' => I18n::_('shuffle_marked'), ], - 'marked_items_count' => [ + 'fontSize' => [ 'format' => 'text', - 'html_before' => I18n::_('entries_limit'), - 'default' => '5', - 'validation' => 'positive_integer', - 'tip' => I18n::_('entries_limit_tip'), - ], - ], - ], - 'portfolio' => [ - 'title' => 'Portfolio', - ], -]; - -$templateConf = [ - 'generalFontSettings' => [ - '_' => [ - 'title' => I18n::_('General font settings'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#1a1a1a', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'color', + 'default' => '11px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-size', + ], ], ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptions, - 'default' => '"Times New Roman", Times, serif', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-weight', + ], ], ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '11px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-size', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-weight', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-style', + ], ], ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-style', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', ], - ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-variant', + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-variant', + ], ], ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'normal', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'line-height', + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'normal', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'line-height', + ], ], ], ], - ], - 'links' => [ - '_' => [ - 'title' => I18n::_('Hyperlinks'), - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#0000ff', - 'title' => I18n::_('Link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'color', - ], - ], - ], - 'colorVisited' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Visited link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'color', - ], - ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#0000ff', - 'title' => I18n::_('Link color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'color', - ], - ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#0000ff', - 'title' => I18n::_('Link color when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'color', - ], - ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'text-decoration', + 'links' => [ + '_' => [ + 'title' => $translate('Hyperlinks'), + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#0000ff', + 'title' => $translate('Link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'color', + ], + ], + ], + 'colorVisited' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Visited link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#0000ff', + 'title' => $translate('Link color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#0000ff', + 'title' => $translate('Link color when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationVisited' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Visited link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Link decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', ], - ], - ], - 'textDecorationVisited' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Visited link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'text-decoration', + 'default' => 'underline', + 'title' => $translate('Link decoration when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'text-decoration', + ], ], ], ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'text-decoration', + + 'background' => [ + '_' => [ + 'title' => $translate('Background'), + ], + 'backgroundColor' => [ + 'format' => 'color', + 'default' => '#FFFFFF', + 'title' => $translate('Background color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-color', + ], + ], + ], + 'backgroundImageEnabled' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Is background image enabled?'), + 'description' => '', ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'text-decoration', + 'backgroundImage' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'affectsStyle' => true, + 'title' => $translate('Background image'), + 'description' => $translate('Picture to use for page background.'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-image', + ], + ], + ], + 'backgroundRepeat' => [ + 'format' => 'select', + 'values' => [ + 'repeat' => 'tile vertically and horizontally', + 'repeat-x' => 'tile horizontally', + 'repeat-y' => 'tile vertically', + 'no-repeat' => 'no tiling', + ], + 'default' => 'repeat', + 'title' => $translate('Background tiling'), + 'description' => $translate('How the background fills the screen?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-repeat', + ], + ], + ], + 'backgroundPosition' => [ + 'format' => 'select', + 'values' => [ + 'top left', + 'top center', + 'top right', + 'center left', + 'center', + 'center right', + 'bottom left', + 'bottom center', + 'bottom right', + ], + 'default' => 'top left', + 'title' => $translate('Background alignment'), + 'description' => $translate('Where the background image is positioned?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-position', + ], + ], + ], + 'backgroundAttachment' => [ + 'format' => 'select', + 'values' => [ + 'fixed' => 'Fixed to browser window', + 'fill' => 'Filled in browser window', + 'scroll' => 'No stretch, scroll along with content', ], + 'default' => 'scroll', + 'title' => $translate('Background position'), + 'description' => $translate('Sets how background behaves in relation with the browser window.'), ], ], - ], - 'background' => [ - '_' => [ - 'title' => I18n::_('Background'), - ], - 'backgroundColor' => [ - 'format' => 'color', - 'default' => '#FFFFFF', - 'title' => I18n::_('Background color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-color', + 'pageLayout' => [ + '_' => [ + 'title' => $translate('Page layout'), + ], + 'centered' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'yes', + 'no', ], + 'title' => $translate('Centered layout'), + 'description' => $translate('Sets whether layout should be centered or not.'), ], - ], - 'backgroundImageEnabled' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Is background image enabled?'), - 'description' => '', - ], - 'backgroundImage' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'affectsStyle' => true, - 'title' => I18n::_('Background image'), - 'description' => I18n::_('Picture to use for page background.'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-image', + 'contentWidth' => [ + 'format' => 'text', + 'default' => '380px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Entry text max width'), + 'description' => $translate('Width of texts in the entries. This does not apply to the width of images.'), + 'css' => [ + [ + 'selector' => '#firstPageMarkedEntries .xEntry', + 'property' => 'max-width', + ], + [ + 'selector' => '#mainColumn', + 'property' => 'max-width', + ], + ], + ], + 'paddingTop' => [ + 'format' => 'text', + 'default' => '30px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('How far content is from page top?'), + 'description' => $translate('The vertical distance between the top of the page and the content area.'), + 'css' => [ + [ + 'selector' => '#mainColumn', + 'property' => 'padding-top', + ], ], ], - ], - 'backgroundRepeat' => [ - 'format' => 'select', - 'values' => [ - 'repeat' => 'tile vertically and horizontally', - 'repeat-x' => 'tile horizontally', - 'repeat-y' => 'tile vertically', - 'no-repeat' => 'no tiling', - ], - 'default' => 'repeat', - 'title' => I18n::_('Background tiling'), - 'description' => I18n::_('How the background fills the screen?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-repeat', + 'paddingLeft' => [ + 'format' => 'text', + 'default' => '30px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('How far content is from sidebar?'), + 'description' => $translate('The horizontal distance between the menu and the content area.'), + 'css' => [ + [ + 'selector' => '#mainColumn', + 'property' => 'padding-left', + ], + [ + 'selector' => '#mainColumn', + 'property' => 'padding-right', + ], + ], + ], + 'group_responsive' => [ + 'format' => false, + 'default' => false, + 'title' => $translate('Responsive design'), + ], + 'responsive' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'no', + 'yes', ], + 'title' => $translate('Enabled'), + 'description' => $translate('Sets whether layout should be responsive or not.'), ], - ], - 'backgroundPosition' => [ - 'format' => 'select', - 'values' => [ - 'top left', - 'top center', - 'top right', - 'center left', - 'center', - 'center right', - 'bottom left', - 'bottom center', - 'bottom right', - ], - 'default' => 'top left', - 'title' => I18n::_('Background alignment'), - 'description' => I18n::_('Where the background image is positioned?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-position', + 'mashUpColumns' => [ + 'format' => 'select', + 'default' => '1', + 'values' => [ + '1', + '2', + '3', + '4', ], + 'title' => $translate('Mash-up columns'), + 'description' => $translate('Column count for Mash-up section.'), ], ], - 'backgroundAttachment' => [ - 'format' => 'select', - 'values' => [ - 'fixed' => 'Fixed to browser window', - 'fill' => 'Filled in browser window', - 'scroll' => 'No stretch, scroll along with content', - ], - 'default' => 'scroll', - 'title' => I18n::_('Background position'), - 'description' => I18n::_('Sets how background behaves in relation with the browser window.'), - ], - ], - 'pageLayout' => [ - '_' => [ - 'title' => I18n::_('Page layout'), - ], - 'centered' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'yes', - 'no', - ], - 'title' => I18n::_('Centered layout'), - 'description' => I18n::_('Sets whether layout should be centered or not.'), - ], - 'contentWidth' => [ - 'format' => 'text', - 'default' => '380px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Entry text max width'), - 'description' => I18n::_('Width of texts in the entries. This does not apply to the width of images.'), - 'css' => [ - [ - 'selector' => '#firstPageMarkedEntries .xEntry', - 'property' => 'max-width', - ], - [ - 'selector' => '#mainColumn', - 'property' => 'max-width', - ], + 'firstPage' => [ + '_' => [ + 'title' => $translate('First page'), ], - ], - 'paddingTop' => [ - 'format' => 'text', - 'default' => '30px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('How far content is from page top?'), - 'description' => I18n::_('The vertical distance between the top of the page and the content area.'), - 'css' => [ - [ - 'selector' => '#mainColumn', - 'property' => 'padding-top', + 'imageSizeRatio' => [ + 'format' => 'text', + 'default' => '0.4', + 'allow_blank' => true, + 'validation' => 'positive_number', + 'title' => $translate('Image size ratio'), + 'description' => $translate('Images in the first page layout will be resized by this ratio. Think of it as percentage, e.g., 0.7 = 70% of the original image size.'), + 'css' => [ + [ + 'selector' => '.bt-responsive #firstPageMarkedEntries .xEntry', + 'property' => 'max-width', + 'template' => '`${value * 100}%`', + ], + ], + ], + 'imageHaveShadows' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Images have shadows?'), + 'description' => '', ], - ], - 'paddingLeft' => [ - 'format' => 'text', - 'default' => '30px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('How far content is from sidebar?'), - 'description' => I18n::_('The horizontal distance between the menu and the content area.'), - 'css' => [ - [ - 'selector' => '#mainColumn', - 'property' => 'padding-left', - ], - [ - 'selector' => '#mainColumn', - 'property' => 'padding-right', + 'hoverWiggle' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'yes', + 'title' => $translate('Images wiggle on mouse-over?'), + 'description' => '', ], ], - 'group_responsive' => [ - 'format' => false, - 'default' => false, - 'title' => I18n::_('Responsive design'), - ], - 'responsive' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'no', - 'yes', - ], - 'title' => I18n::_('Enabled'), - 'description' => I18n::_('Sets whether layout should be responsive or not.'), - ], - 'mashUpColumns' => [ - 'format' => 'select', - 'default' => '1', - 'values' => [ - '1', - '2', - '3', - '4', - ], - 'title' => I18n::_('Mash-up columns'), - 'description' => I18n::_('Column count for Mash-up section.'), - ], - ], - 'firstPage' => [ - '_' => [ - 'title' => I18n::_('First page'), - ], - 'imageSizeRatio' => [ - 'format' => 'text', - 'default' => '0.4', - 'allow_blank' => true, - 'validation' => 'positive_number', - 'title' => I18n::_('Image size ratio'), - 'description' => I18n::_('Images in the first page layout will be resized by this ratio. Think of it as percentage, e.g., 0.7 = 70% of the original image size.'), - 'css' => [ - [ - 'selector' => '.bt-responsive #firstPageMarkedEntries .xEntry', - 'property' => 'max-width', - 'template' => '`${value * 100}%`', - ], + 'sideBar' => [ + '_' => [ + 'title' => $translate('Sidebar'), ], - ], - 'imageHaveShadows' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Images have shadows?'), - 'description' => '', - ], - 'hoverWiggle' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'yes', - 'title' => I18n::_('Images wiggle on mouse-over?'), - 'description' => '', - ], - ], - - 'sideBar' => [ - '_' => [ - 'title' => I18n::_('Sidebar'), - ], - 'width' => [ - 'format' => 'text', - 'default' => '200px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Width'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumn', - 'property' => 'width', - ], - [ - 'selector' => '#mainColumn', - 'property' => 'margin-left', - ], - [ - 'selector' => '.xNarrow #mainColumn.xCentered', - 'property' => 'margin-left', - ], - [ - 'selector' => '.floating-banners', - 'property' => 'margin-left', + 'width' => [ + 'format' => 'text', + 'default' => '200px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Width'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumn', + 'property' => 'width', + ], + [ + 'selector' => '#mainColumn', + 'property' => 'margin-left', + ], + [ + 'selector' => '.xNarrow #mainColumn.xCentered', + 'property' => 'margin-left', + ], + [ + 'selector' => '.floating-banners', + 'property' => 'margin-left', + ], + ], + ], + 'marginLeft' => [ + 'format' => 'text', + 'default' => '0px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Left margin'), + 'description' => $translate('How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.'), + 'css' => [ + [ + 'selector' => '#mainColumnContainer', + 'property' => 'padding-left', + ], + [ + 'selector' => '#sideColumn', + 'property' => 'left', + ], + ], + ], + 'marginTop' => [ + 'format' => 'text', + 'default' => '30px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Top padding'), + 'description' => $translate('How far the header is from the top of the screen?'), + 'css' => [ + [ + 'selector' => '#sideColumnTop', + 'property' => 'padding-top', + ], ], ], - ], - 'marginLeft' => [ - 'format' => 'text', - 'default' => '0px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Left margin'), - 'description' => I18n::_('How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.'), - 'css' => [ - [ - 'selector' => '#mainColumnContainer', - 'property' => 'padding-left', - ], - [ - 'selector' => '#sideColumn', - 'property' => 'left', + 'marginBottom' => [ + 'format' => 'text', + 'default' => '20px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between header and menu'), + 'description' => $translate('How far the menu is from the header text or header image.'), + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'margin-bottom', + ], ], ], - ], - 'marginTop' => [ - 'format' => 'text', - 'default' => '30px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Top padding'), - 'description' => I18n::_('How far the header is from the top of the screen?'), - 'css' => [ - [ - 'selector' => '#sideColumnTop', - 'property' => 'padding-top', + 'transparent' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Is transparent?'), + 'description' => '', ], - ], - 'marginBottom' => [ - 'format' => 'text', - 'default' => '20px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between header and menu'), - 'description' => I18n::_('How far the menu is from the header text or header image.'), - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'margin-bottom', - ], + 'backgroundColor' => [ + 'format' => 'color', + 'default' => '#ffffff', + 'title' => $translate('Background color'), + 'description' => '', ], - ], - 'transparent' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Is transparent?'), - 'description' => '', - ], - 'backgroundColor' => [ - 'format' => 'color', - 'default' => '#ffffff', - 'title' => I18n::_('Background color'), - 'description' => '', - ], - 'image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 'setting:template:sideBar:width', - 'max_height' => 1600, - 'title' => I18n::_('Logo image'), - 'description' => '' . I18n::_('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', - ], - 'color' => [ - 'format' => 'color', - 'default' => '#1a1a1a', - 'title' => I18n::_('Heading text color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop h1 a', - 'property' => 'color', - 'important' => true, - ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'Georgia, "Times New Roman", Times, serif', - 'title' => I18n::_('Heading font'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '10px', - 'allow_blank' => true, - 'title' => I18n::_('Heading font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-size', + 'image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 'setting:template:sideBar:width', + 'max_height' => 1600, + 'title' => $translate('Logo image'), + 'description' => '' . $translate('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', + ], + 'color' => [ + 'format' => 'color', + 'default' => '#1a1a1a', + 'title' => $translate('Heading text color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop h1 a', + 'property' => 'color', + 'important' => true, + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'Georgia, "Times New Roman", Times, serif', + 'title' => $translate('Heading font'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-family', + ], ], ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Heading font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-weight', + 'fontSize' => [ + 'format' => 'text', + 'default' => '10px', + 'allow_blank' => true, + 'title' => $translate('Heading font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-size', + ], ], ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Heading font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-style', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Heading font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-variant', + 'default' => 'normal', + 'title' => $translate('Heading font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-weight', + ], ], ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'title' => I18n::_('Heading line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'line-height', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - ], - - 'additionalText' => [ - '_' => [ - 'title' => I18n::_('Additional text'), - ], - 'color' => [ - 'format' => 'color', - 'default' => 'inherit', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'color', + 'default' => 'normal', + 'title' => $translate('Heading font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-style', + ], ], ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', + 'default' => 'normal', + 'title' => $translate('Heading font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-variant', + ], ], ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-size', + 'lineHeight' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'title' => $translate('Heading line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'line-height', + ], ], ], ], - ], - 'menu' => [ - '_' => [ - 'title' => I18n::_('Main menu'), - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop ul li', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#sideColumnTop ul li', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '10px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop ul li', - 'property' => 'font-size', - ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop ul li', - 'property' => 'font-weight', - ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop ul li', - 'property' => 'font-style', + 'additionalText' => [ + '_' => [ + 'title' => $translate('Additional text'), + ], + 'color' => [ + 'format' => 'color', + 'default' => 'inherit', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], ], ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '16px', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#sideColumnTop ul li', - 'property' => 'line-height', + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-size', + ], ], ], ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#1a1a1a', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop li a:link', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop li a:visited', - 'property' => 'color', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a', - 'property' => 'color', + + 'menu' => [ + '_' => [ + 'title' => $translate('Main menu'), + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop ul li', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#sideColumnTop ul li', + 'property' => 'font-family', + ], ], ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#0000ff', - 'title' => I18n::_('Color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop li a:hover', - 'property' => 'color', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', - 'property' => 'color', + 'fontSize' => [ + 'format' => 'text', + 'default' => '10px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop ul li', + 'property' => 'font-size', + ], ], ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#1a1a1a', - 'title' => I18n::_('Color when opened'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop li a:active', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop li.selected > a', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop li.selected > span', - 'property' => 'color', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', - 'property' => 'color', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop ul li', + 'property' => 'font-weight', + ], ], ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop li a:link', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop li a:visited', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a', - 'property' => 'text-decoration', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop li a:hover', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop ul li', + 'property' => 'font-style', + ], ], ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Decoration when opened'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop li a:active', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop li.selected > a', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop li.selected > span', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', - 'property' => 'text-decoration', + 'lineHeight' => [ + 'format' => 'text', + 'default' => '16px', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#sideColumnTop ul li', + 'property' => 'line-height', + ], + ], + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#1a1a1a', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop li a:link', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop li a:visited', + 'property' => 'color', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#0000ff', + 'title' => $translate('Color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop li a:hover', + 'property' => 'color', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#1a1a1a', + 'title' => $translate('Color when opened'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop li a:active', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop li.selected > a', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop li.selected > span', + 'property' => 'color', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop li a:link', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop li a:visited', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop li a:hover', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Decoration when opened'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop li a:active', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop li.selected > a', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop li.selected > span', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', + 'property' => 'text-decoration', + ], ], ], ], - ], - 'tagsMenu' => [ - '_' => [ - 'title' => I18n::_('Submenu'), - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', - 'property' => 'font-family', + 'tagsMenu' => [ + '_' => [ + 'title' => $translate('Submenu'), + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', + 'property' => 'font-family', + ], ], ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', - 'property' => 'font-family', + 'fontSize' => [ + 'format' => 'text', + 'default' => '11px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', + 'property' => 'font-size', + ], ], ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '11px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', - 'property' => 'font-size', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', - 'property' => 'font-weight', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', + 'property' => 'font-weight', + ], ], ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', - 'property' => 'font-style', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '16px', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', - 'property' => 'line-height', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', + 'property' => 'font-style', + ], ], ], - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#1a1a1a', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:link', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:visited', - 'property' => 'color', + 'lineHeight' => [ + 'format' => 'text', + 'default' => '16px', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li', + 'property' => 'line-height', + ], + ], + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#1a1a1a', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:link', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#0000ff', + 'title' => $translate('Color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#1a1a1a', + 'title' => $translate('Color when selected'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:active', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li.selected > a', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:link', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:visited', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'line-through', + 'title' => $translate('Decoration when opened'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:active', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li.selected > a', + 'property' => 'text-decoration', + ], ], ], ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#0000ff', - 'title' => I18n::_('Color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:hover', - 'property' => 'color', - ], + + 'entryLayout' => [ + '_' => [ + 'title' => $translate('Entry layout'), ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#1a1a1a', - 'title' => I18n::_('Color when selected'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:active', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li.selected > a', - 'property' => 'color', + 'spaceBetween' => [ + 'format' => 'text', + 'default' => '20px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between entries'), + 'description' => $translate('Distance from entry to entry. In pixels.'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry', + 'property' => 'margin-bottom', + ], ], ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:link', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:visited', - 'property' => 'text-decoration', + 'defaultGalleryType' => [ + 'format' => 'select', + 'values' => [ + 'slideshow', + 'row', + 'grid', ], + 'default' => 'slideshow', + 'title' => $translate('Default gallery type'), + 'description' => '', ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:hover', - 'property' => 'text-decoration', + 'spaceBetweenImages' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images in row and column'), + 'description' => $translate('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), + 'css' => [ + [ + 'selector' => '.xGalleryType-column div.xGalleryItem', + 'property' => 'padding-bottom', + ], + [ + 'selector' => '.xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', + 'property' => 'margin-right', + ], + [ + 'selector' => '.xSectionType-portfolio #pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', + 'property' => 'padding-bottom', + ], + [ + 'selector' => '.bt-responsive #pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', + 'property' => 'padding-bottom', + 'breakpoint' => '(max-width: 767px)', + ], + ], + ], + 'galleryNavMargin' => [ + 'format' => 'text', + 'default' => '5px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images and image navigation'), + 'description' => $translate('Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .xGalleryType-slideshow .xGallery', + 'property' => 'margin-bottom', + ], ], ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'line-through', - 'title' => I18n::_('Decoration when opened'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li a:active', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop .bt-sections-menu .subMenu li.selected > a', - 'property' => 'text-decoration', + 'galleryMargin' => [ + 'format' => 'text', + 'default' => '5px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Empty space below gallery'), + 'description' => $translate('Distance between the gallery and the content below'), + 'css' => [ + [ + 'selector' => '#pageEntries li.xEntry .xGalleryContainer', + 'property' => 'margin-bottom', + ], ], ], ], - ], - 'entryLayout' => [ - '_' => [ - 'title' => I18n::_('Entry layout'), - ], - 'spaceBetween' => [ - 'format' => 'text', - 'default' => '20px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between entries'), - 'description' => I18n::_('Distance from entry to entry. In pixels.'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry', - 'property' => 'margin-bottom', - ], + 'socialMediaLinks' => [ + '_' => [ + 'title' => $translate('Social media buttons'), ], - ], - 'defaultGalleryType' => [ - 'format' => 'select', - 'values' => [ - 'slideshow', - 'row', - 'grid', - ], - 'default' => 'slideshow', - 'title' => I18n::_('Default gallery type'), - 'description' => '', - ], - 'spaceBetweenImages' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images in row and column'), - 'description' => I18n::_('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), - 'css' => [ - [ - 'selector' => '.xGalleryType-column div.xGalleryItem', - 'property' => 'padding-bottom', - ], - [ - 'selector' => '.xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', - 'property' => 'margin-right', - ], - [ - 'selector' => '.xSectionType-portfolio #pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', - 'property' => 'padding-bottom', - ], - [ - 'selector' => '.bt-responsive #pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', - 'property' => 'padding-bottom', - 'breakpoint' => '(max-width: 767px)', + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Button color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.social-icon path', + 'property' => 'fill', + ], ], ], ], - 'galleryNavMargin' => [ - 'format' => 'text', - 'default' => '5px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images and image navigation'), - 'description' => I18n::_('Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .xGalleryType-slideshow .xGallery', - 'property' => 'margin-bottom', - ], + + 'css' => [ + '_' => [ + 'title' => $translate('Custom CSS'), ], - ], - 'galleryMargin' => [ - 'format' => 'text', - 'default' => '5px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Empty space below gallery'), - 'description' => I18n::_('Distance between the gallery and the content below'), - 'css' => [ - [ - 'selector' => '#pageEntries li.xEntry .xGalleryContainer', - 'property' => 'margin-bottom', - ], + 'customCSS' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Custom CSS'), + 'description' => $translate('custom_css_description'), ], ], - ], - 'socialMediaLinks' => [ - '_' => [ - 'title' => I18n::_('Social media buttons'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Button color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.social-icon path', - 'property' => 'fill', - ], + 'settings' => [ + '_' => [ + 'title' => $translate('Other settings'), + ], + 'resetTemplate' => [ + 'format' => 'action', + 'default' => 'resetTemplate', + 'title' => $translate('Reset design'), + 'description' => $translate('Reset design to default values.'), ], ], - ], - - 'css' => [ - '_' => [ - 'title' => I18n::_('Custom CSS'), - ], - 'customCSS' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Custom CSS'), - 'description' => I18n::_('description_custom_css'), - ], - ], + ]; - 'settings' => [ - '_' => [ - 'title' => I18n::_('Other settings'), + $translations = [ + 'sectionTypes' => [ + 'type' => $translate('section_type'), + 'type_tip' => $translate('section_type_tip'), ], - 'resetTemplate' => [ - 'format' => 'action', - 'default' => 'resetTemplate', - 'title' => I18n::_('Reset design'), - 'description' => I18n::_('Reset design to default values.'), - ], - ], -]; - -$translations = [ - 'sectionTypes' => [ - 'type' => I18n::_('section_type'), - 'type_tip' => I18n::_('section_type_tip'), - ], -]; + ]; -return [$sectionTypes, $templateConf, $translations]; + return [$sectionTypes, $templateConf, $translations]; +}; diff --git a/_templates/messy-0.4.2/template.conf.php b/_templates/messy-0.4.2/template.conf.php index 39c75281e..ed4534366 100644 --- a/_templates/messy-0.4.2/template.conf.php +++ b/_templates/messy-0.4.2/template.conf.php @@ -1,1697 +1,1687 @@ 'Arial, sans-serif', - 'Helvetica, Arial, sans-serif' => 'Helvetica, Arial, sans-serif', - '"Helvetica Neue", Helvetica, Arial, sans-serif' => 'Helvetica Neue, Helvetica, Arial, sans-serif', - '"Arial Black", Gadget, sans-serif' => 'Arial Black, Gadget', - '"Comic Sans MS", cursive' => 'Comic Sans MS', - '"Courier New", Courier, monospace' => 'Courier New, Courier', - 'Georgia, "Times New Roman", Times, serif' => 'Georgia, Times New Roman, Times', - 'Impact, Charcoal, sans-serif' => 'Impact, Charcoal', - '"Lucida Console", Monaco, monospace' => 'Lucida Console, Monaco', - '"Lucida Sans Unicode", "Lucida Grande", sans-serif' => 'Lucida Sans Unicode, Lucida Grande', - '"Palatino Linotype", "Book Antiqua", Palatino, serif' => 'Palatino Linotype, Book Antiqua, Palatino', - 'Tahoma, Geneva, sans-serif' => 'Tahoma, Geneva', - '"Times New Roman", Times, serif' => 'Times New Roman, Times', - '"Trebuchet MS", Helvetica, sans-serif' => 'Trebuchet MS, Helvetica', - 'Verdana, Geneva, sans-serif' => 'Verdana, Geneva', -]; -$fontOptionsWithInherit = array_merge([ - 'inherit' => '(inherit from general-font-settings)', -], $fontOptions); +/** + * The template's section types and settings: their default values for the site engine, and their titles and + * descriptions for the editor. `$translate` translates a title or description into the editor's language. + * + * @return Closure(callable(string): string, bool): array{0: array, 1: array, 2: array} + */ +return static function (callable $translate, bool $isShopEnabled): array { + ['fonts' => $fontOptions, 'withInherit' => $fontOptionsWithInherit] = require dirname(__DIR__, 2) . '/engine/inc.font-options.php'; -$sectionTypes = [ - 'default' => [ - 'title' => 'Default', - 'params' => [ - 'columns' => [ - 'format' => 'select', - 'default' => '1', - 'values' => [ - '1', - '2', - '3', - '4', + $sectionTypes = [ + 'default' => [ + 'title' => 'Default', + 'params' => [ + 'columns' => [ + 'format' => 'select', + 'default' => '1', + 'values' => [ + '1', + '2', + '3', + '4', + ], + 'html_before' => '
            ' . $translate('Columns') . ':
            ', + ], + 'entryMaxWidth' => [ + 'format' => 'text', + 'css_units' => true, + 'default' => '', + 'allow_blank' => true, + 'html_before' => '
            ' . $translate('Entry max width') . ':
            ', + ], + 'entryPadding' => [ + 'format' => 'text', + 'default' => '0 10px 20px 10px', + 'html_before' => '
            ' . $translate('Entry padding') . ':
            ', + ], + 'backgroundVideoEmbed' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'html_before' => '
            ' . $translate('Background video (embed)') . ':
            ', + ], + 'backgroundVideoRatio' => [ + 'format' => 'select', + 'default' => 'fillWindow', + 'values' => [ + 'fillWindow' => $translate('Fill window'), + 'keepRatio' => $translate('Keep ratio'), + ], + 'html_before' => '
            ' . $translate('Background video ratio') . ':
            ', ], - 'html_before' => '
            ' . I18n::_('Columns') . ':
            ', - ], - 'entryMaxWidth' => [ - 'format' => 'text', - 'css_units' => true, - 'default' => '', - 'allow_blank' => true, - 'html_before' => '
            ' . I18n::_('Entry max width') . ':
            ', - ], - 'entryPadding' => [ - 'format' => 'text', - 'default' => '0 10px 20px 10px', - 'html_before' => '
            ' . I18n::_('Entry padding') . ':
            ', ], - 'backgroundVideoEmbed' => [ + ], + + 'external_link' => [ + 'title' => 'External link', + 'params' => [ + 'link' => [ + 'format' => 'url', + 'default' => '', + 'allow_blank' => true, + 'link' => true, + 'html_before' => '
            ' . $translate('Link address') . ':
            ', + ], + 'target' => [ + 'format' => 'select', + 'values' => [ + '_self' => 'Same window', + '_blank' => 'New window', + ], + 'default' => '_blank', + 'html_before' => '
            ' . $translate('Opens in') . ':
            ', + ], + ], + ], + 'grid' => [ + 'title' => 'Thumbnails enabled', + ], + 'portfolio' => [ + 'title' => 'Portfolio', + ], + ]; + + $templateConf = [ + 'generalFontSettings' => [ + '_' => [ + 'title' => $translate('General font settings'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#363636', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptions, + 'default' => 'Arial, sans-serif', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ 'format' => 'text', 'default' => '', 'allow_blank' => true, 'html_entities' => true, - 'html_before' => '
            ' . I18n::_('Background video (embed)') . ':
            ', - ], - 'backgroundVideoRatio' => [ - 'format' => 'select', - 'default' => 'fillWindow', - 'values' => [ - 'fillWindow' => I18n::_('Fill window'), - 'keepRatio' => I18n::_('Keep ratio'), + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], ], - 'html_before' => '
            ' . I18n::_('Background video ratio') . ':
            ', ], - ], - ], - - 'external_link' => [ - 'title' => 'External link', - 'params' => [ - 'link' => [ - 'format' => 'url', - 'default' => '', + 'fontSize' => [ + 'format' => 'text', + 'default' => '12px', 'allow_blank' => true, - 'link' => true, - 'html_before' => '
            ' . I18n::_('Link address') . ':
            ', + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-size', + ], + ], ], - 'target' => [ + 'fontWeight' => [ 'format' => 'select', 'values' => [ - '_self' => 'Same window', - '_blank' => 'New window', - ], - 'default' => '_blank', - 'html_before' => '
            ' . I18n::_('Opens in') . ':
            ', - ], - ], - ], - 'grid' => [ - 'title' => 'Thumbnails enabled', - ], - 'portfolio' => [ - 'title' => 'Portfolio', - ], -]; - -$templateConf = [ - 'generalFontSettings' => [ - '_' => [ - 'title' => I18n::_('General font settings'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#363636', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'color', + 'normal', + 'bold', ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptions, - 'default' => 'Arial, sans-serif', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-weight', + ], ], ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '12px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-size', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-style', + ], ], ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-weight', - ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-style', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', ], - ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-variant', + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-variant', + ], ], ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '18px', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'line-height', + 'lineHeight' => [ + 'format' => 'text', + 'default' => '18px', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'line-height', + ], ], ], ], - ], - 'links' => [ - '_' => [ - 'title' => I18n::_('Hyperlinks'), - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'color', - ], - ], - ], - 'colorVisited' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Visited link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'color', - ], - ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Link color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'color', - ], - ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Link color when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'color', - ], - ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'text-decoration', - ], - ], - ], - 'textDecorationVisited' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Visited link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'text-decoration', - ], - ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'text-decoration', + 'links' => [ + '_' => [ + 'title' => $translate('Hyperlinks'), + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'color', + ], + ], + ], + 'colorVisited' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Visited link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Link color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Link color when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationVisited' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Visited link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Link decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', ], - ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'text-decoration', + 'default' => 'underline', + 'title' => $translate('Link decoration when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'text-decoration', + ], ], ], ], - ], - 'background' => [ - '_' => [ - 'title' => I18n::_('Background'), - ], - 'backgroundColor' => [ - 'format' => 'color', - 'default' => '#FFFFFF', - 'title' => I18n::_('Background color'), - 'description' => I18n::_('IMPORTANT! These settings will be overwritten, if you are using background gallery feature. You access it by clicking "edit background gallery" button in each section.'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-color', - ], - ], - ], - 'backgroundImageEnabled' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Is background image enabled?'), - 'description' => '', - ], - 'backgroundImage' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'affectsStyle' => true, - 'title' => I18n::_('Background image'), - 'description' => I18n::_('Picture to use for page background.'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-image', - ], - ], - ], - 'backgroundRepeat' => [ - 'format' => 'select', - 'values' => [ - 'repeat' => 'tile vertically and horizontally', - 'repeat-x' => 'tile horizontally', - 'repeat-y' => 'tile vertically', - 'no-repeat' => 'no tiling', - ], - 'default' => 'repeat', - 'title' => I18n::_('Background tiling'), - 'description' => I18n::_('How the background fills the screen?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-repeat', + 'background' => [ + '_' => [ + 'title' => $translate('Background'), + ], + 'backgroundColor' => [ + 'format' => 'color', + 'default' => '#FFFFFF', + 'title' => $translate('Background color'), + 'description' => $translate('IMPORTANT! These settings will be overwritten, if you are using background gallery feature. You access it by clicking "edit background gallery" button in each section.'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-color', + ], + ], + ], + 'backgroundImageEnabled' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Is background image enabled?'), + 'description' => '', ], - ], - 'backgroundPosition' => [ - 'format' => 'select', - 'values' => [ - 'top left', - 'top center', - 'top right', - 'center left', - 'center', - 'center right', - 'bottom left', - 'bottom center', - 'bottom right', - ], - 'default' => 'top left', - 'title' => I18n::_('Background alignment'), - 'description' => I18n::_('Where the background image is positioned?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-position', + 'backgroundImage' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'affectsStyle' => true, + 'title' => $translate('Background image'), + 'description' => $translate('Picture to use for page background.'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-image', + ], + ], + ], + 'backgroundRepeat' => [ + 'format' => 'select', + 'values' => [ + 'repeat' => 'tile vertically and horizontally', + 'repeat-x' => 'tile horizontally', + 'repeat-y' => 'tile vertically', + 'no-repeat' => 'no tiling', + ], + 'default' => 'repeat', + 'title' => $translate('Background tiling'), + 'description' => $translate('How the background fills the screen?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-repeat', + ], + ], + ], + 'backgroundPosition' => [ + 'format' => 'select', + 'values' => [ + 'top left', + 'top center', + 'top right', + 'center left', + 'center', + 'center right', + 'bottom left', + 'bottom center', + 'bottom right', + ], + 'default' => 'top left', + 'title' => $translate('Background alignment'), + 'description' => $translate('Where the background image is positioned?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-position', + ], + ], + ], + 'backgroundAttachment' => [ + 'format' => 'select', + 'values' => [ + 'fixed' => 'Fixed to browser window', + 'fill' => 'Filled in browser window', + 'scroll' => 'No stretch, scroll along with content', ], + 'default' => 'scroll', + 'title' => $translate('Background position'), + 'description' => $translate('Sets how background behaves in relation with the browser window.'), ], ], - 'backgroundAttachment' => [ - 'format' => 'select', - 'values' => [ - 'fixed' => 'Fixed to browser window', - 'fill' => 'Filled in browser window', - 'scroll' => 'No stretch, scroll along with content', - ], - 'default' => 'scroll', - 'title' => I18n::_('Background position'), - 'description' => I18n::_('Sets how background behaves in relation with the browser window.'), - ], - ], - - 'grid' => [ - '_' => [ - 'title' => I18n::_('Thumbnails'), - ], - 'whatAreThumbnails' => [ - 'format' => '', - 'default' => '', - 'title' => I18n::_('Thumbnails can be turned on by setting the section type to "Thumbnails enabled" & adding more than 1 images to background gallery.'), - ], - 'contentWidth' => [ - 'format' => 'text', - 'default' => '60%', - 'allow_blank' => true, - 'title' => I18n::_('Thumbnail container width'), - 'description' => I18n::_('IMPORTANT! This must be set as percentage. i.e. 60%'), - ], - ], - 'pageLayout' => [ - '_' => [ - 'title' => I18n::_('Page layout'), - ], - 'bgButtonType' => [ - 'format' => 'select', - 'default' => 'dark', - 'values' => [ - 'dark', - 'bright', - ], - 'title' => I18n::_('Background button type'), - 'description' => I18n::_('Select type for background gallery buttons.'), - ], - 'centered' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'yes', - 'no', - ], - 'title' => I18n::_('Centered layout'), - 'description' => I18n::_('Sets whether layout should be centered or not.'), - ], - 'centeredWidth' => [ - 'format' => 'text', - 'default' => '960px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Centered content width'), - 'description' => I18n::_('Content width if layout is centered.'), - 'css' => [ - [ - 'selector' => 'body:not(.bt-responsive):not(.bt-auto-responsive) #contentContainer.xCentered', - 'property' => 'width', - ], - [ - 'selector' => '.xCenteringGuide', - 'property' => 'width', - ], - [ - 'selector' => '.bt-responsive #contentContainer', - 'property' => 'max-width', - ], - [ - 'selector' => '.bt-auto-responsive #contentContainer.xCentered', - 'property' => 'max-width', - ], - [ - 'selector' => '.bt-auto-responsive #contentContainer', - 'property' => 'max-width', - 'breakpoint' => '(max-width: 767px)', - ], + 'grid' => [ + '_' => [ + 'title' => $translate('Thumbnails'), ], - ], - 'centeringGuidesColor' => [ - 'format' => 'select', - 'default' => 'dark', - 'values' => [ - 'dark', - 'bright', - ], - 'title' => I18n::_('Centering guides color tone'), - 'description' => I18n::_('Color tone for centering guides (dark for bright background colors, bright for dark background colors).'), - ], - - 'group_responsive' => [ - 'format' => false, - 'default' => false, - 'title' => I18n::_('Responsive design'), - ], - 'autoResponsive' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'title' => I18n::_('Responsive layout'), - 'description' => I18n::_('Enables responsive layout for mobile devices.'), - ], - 'responsive' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'no', - 'yes', - ], - 'title' => I18n::_('Grid layout'), - 'description' => I18n::_('Entries are organised in columns. Column count can be changed under section settings.'), - ], - 'centeredContents' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'no', - 'yes', - ], - 'title' => I18n::_('Centered contents'), - 'description' => I18n::_('Page heading, menu items and section entries is horizontally centered to window.'), - ], - 'headingMargin' => [ - 'format' => 'text', - 'default' => '20px 10px', - 'allow_blank' => true, - 'title' => I18n::_('Heading margin'), - 'description' => I18n::_('Margin around page heading or logo.'), - 'css' => [ - [ - 'selector' => '.bt-responsive #contentContainer h1', - 'property' => 'margin', - ], - [ - 'selector' => '.bt-auto-responsive #contentContainer h1', - 'property' => 'margin', - 'breakpoint' => '(max-width: 767px)', - ], + 'whatAreThumbnails' => [ + 'format' => '', + 'default' => '', + 'title' => $translate('Thumbnails can be turned on by setting the section type to "Thumbnails enabled" & adding more than 1 images to background gallery.'), ], - ], - 'menuMargin' => [ - 'format' => 'text', - 'default' => '20px 10px', - 'allow_blank' => true, - 'title' => I18n::_('Menu margin'), - 'description' => I18n::_('Margin around menu.'), - 'css' => [ - [ - 'selector' => '.portfolioThumbnailsWrap', - 'property' => 'margin', - ], - [ - 'selector' => '.bt-responsive .bt-sections-menu', - 'property' => 'margin', - ], - [ - 'selector' => '.bt-auto-responsive .bt-sections-menu', - 'property' => 'margin', - 'breakpoint' => '(max-width: 767px)', - ], + 'contentWidth' => [ + 'format' => 'text', + 'default' => '60%', + 'allow_blank' => true, + 'title' => $translate('Thumbnail container width'), + 'description' => $translate('IMPORTANT! This must be set as percentage. i.e. 60%'), ], ], - ], - 'heading' => [ - '_' => [ - 'title' => I18n::_('Page heading'), - ], - 'position' => [ - 'format' => 'select', - 'values' => [ - 'fixed', - 'absolute', - ], - 'default' => 'absolute', - 'title' => I18n::_('Heading position'), - 'description' => I18n::_('description_heading_position'), - // @todo Fix the positing calculation in js. For now reload is required. - // 'css' => [ - // [ - // 'selector' => '#contentContainer h1', - // 'property' => 'position', - // 'important' => true - // ] - // ] - ], - 'image' => [ - 'format' => 'image', - 'default' => '', - 'allow_blank' => true, - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 140, - 'max_height' => 400, - 'title' => I18n::_('Logo image'), - 'description' => '' . I18n::_('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'allow_blank' => true, - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'color', - ], - [ - 'selector' => 'h1 a', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => 'h1 a:link', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => 'h1 a:visited', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => 'h1 a:hover', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => 'h1 a:active', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'color', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'color', - ], + 'pageLayout' => [ + '_' => [ + 'title' => $translate('Page layout'), ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => '"Arial black", Gadget', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'font-family', + 'bgButtonType' => [ + 'format' => 'select', + 'default' => 'dark', + 'values' => [ + 'dark', + 'bright', ], + 'title' => $translate('Background button type'), + 'description' => $translate('Select type for background gallery buttons.'), ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'font-family', - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'font-family', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'font-family', + 'centered' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'yes', + 'no', ], + 'title' => $translate('Centered layout'), + 'description' => $translate('Sets whether layout should be centered or not.'), ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '30px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'font-size', - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'font-size', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'font-size', + 'centeredWidth' => [ + 'format' => 'text', + 'default' => '960px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Centered content width'), + 'description' => $translate('Content width if layout is centered.'), + 'css' => [ + [ + 'selector' => 'body:not(.bt-responsive):not(.bt-auto-responsive) #contentContainer.xCentered', + 'property' => 'width', + ], + [ + 'selector' => '.xCenteringGuide', + 'property' => 'width', + ], + [ + 'selector' => '.bt-responsive #contentContainer', + 'property' => 'max-width', + ], + [ + 'selector' => '.bt-auto-responsive #contentContainer.xCentered', + 'property' => 'max-width', + ], + [ + 'selector' => '.bt-auto-responsive #contentContainer', + 'property' => 'max-width', + 'breakpoint' => '(max-width: 767px)', + ], + ], + ], + 'centeringGuidesColor' => [ + 'format' => 'select', + 'default' => 'dark', + 'values' => [ + 'dark', + 'bright', ], + 'title' => $translate('Centering guides color tone'), + 'description' => $translate('Color tone for centering guides (dark for bright background colors, bright for dark background colors).'), ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', + + 'group_responsive' => [ + 'format' => false, + 'default' => false, + 'title' => $translate('Responsive design'), ], - 'default' => 'bold', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'font-weight', - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'font-weight', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'font-weight', + 'autoResponsive' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', ], + 'title' => $translate('Responsive layout'), + 'description' => $translate('Enables responsive layout for mobile devices.'), ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'font-style', - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'font-style', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'font-style', + 'responsive' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'no', + 'yes', ], + 'title' => $translate('Grid layout'), + 'description' => $translate('Entries are organised in columns. Column count can be changed under section settings.'), ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'font-variant', - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'font-variant', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'font-variant', + 'centeredContents' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'no', + 'yes', ], + 'title' => $translate('Centered contents'), + 'description' => $translate('Page heading, menu items and section entries is horizontally centered to window.'), ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#contentContainer h1', - 'property' => 'line-height', - ], - [ - 'selector' => '#xBackground #xBackgroundRightCounter', - 'property' => 'line-height', - ], - [ - 'selector' => '#xBackground #xBackgroundLeftCounter', - 'property' => 'line-height', + 'headingMargin' => [ + 'format' => 'text', + 'default' => '20px 10px', + 'allow_blank' => true, + 'title' => $translate('Heading margin'), + 'description' => $translate('Margin around page heading or logo.'), + 'css' => [ + [ + 'selector' => '.bt-responsive #contentContainer h1', + 'property' => 'margin', + ], + [ + 'selector' => '.bt-auto-responsive #contentContainer h1', + 'property' => 'margin', + 'breakpoint' => '(max-width: 767px)', + ], + ], + ], + 'menuMargin' => [ + 'format' => 'text', + 'default' => '20px 10px', + 'allow_blank' => true, + 'title' => $translate('Menu margin'), + 'description' => $translate('Margin around menu.'), + 'css' => [ + [ + 'selector' => '.portfolioThumbnailsWrap', + 'property' => 'margin', + ], + [ + 'selector' => '.bt-responsive .bt-sections-menu', + 'property' => 'margin', + ], + [ + 'selector' => '.bt-auto-responsive .bt-sections-menu', + 'property' => 'margin', + 'breakpoint' => '(max-width: 767px)', + ], ], ], ], - ], - 'menu' => [ - '_' => [ - 'title' => I18n::_('Main menu'), - ], - 'position' => [ - 'format' => 'select', - 'values' => [ - 'fixed', - 'absolute', - ], - 'default' => 'absolute', - 'title' => I18n::_('Menu position'), - 'description' => I18n::_('description_menu_position'), - // @todo Fix the positing calculation in js. For now reload is required. - // 'css' => [ - // [ - // 'selector' => '.bt-sections-menu > ul > li', - // 'property' => 'position', - // 'important' => true - // ] - // ] - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => '"Arial black", Gadget', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li', - 'property' => 'font-family', - ], + 'heading' => [ + '_' => [ + 'title' => $translate('Page heading'), ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '20px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li', - 'property' => 'font-size', - ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'bold', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li', - 'property' => 'font-weight', + 'position' => [ + 'format' => 'select', + 'values' => [ + 'fixed', + 'absolute', + ], + 'default' => 'absolute', + 'title' => $translate('Heading position'), + 'description' => $translate('heading_position_description'), + // @todo Fix the positing calculation in js. For now reload is required. + // 'css' => [ + // [ + // 'selector' => '#contentContainer h1', + // 'property' => 'position', + // 'important' => true + // ] + // ] + ], + 'image' => [ + 'format' => 'image', + 'default' => '', + 'allow_blank' => true, + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 140, + 'max_height' => 400, + 'title' => $translate('Logo image'), + 'description' => '' . $translate('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', + ], + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'allow_blank' => true, + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'color', + ], + [ + 'selector' => 'h1 a', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => 'h1 a:link', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => 'h1 a:visited', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => 'h1 a:hover', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => 'h1 a:active', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'color', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => '"Arial black", Gadget', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'font-family', + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'font-family', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '30px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'font-size', + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'font-size', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'font-size', + ], + ], + ], + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', + ], + 'default' => 'bold', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'font-weight', + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'font-weight', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'font-weight', + ], + ], + ], + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', + ], + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'font-style', + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'font-style', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'font-style', + ], + ], + ], + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'font-variant', + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'font-variant', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#contentContainer h1', + 'property' => 'line-height', + ], + [ + 'selector' => '#xBackground #xBackgroundRightCounter', + 'property' => 'line-height', + ], + [ + 'selector' => '#xBackground #xBackgroundLeftCounter', + 'property' => 'line-height', + ], ], ], ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li', - 'property' => 'font-style', - ], + + 'menu' => [ + '_' => [ + 'title' => $translate('Main menu'), ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li', - 'property' => 'line-height', + 'position' => [ + 'format' => 'select', + 'values' => [ + 'fixed', + 'absolute', + ], + 'default' => 'absolute', + 'title' => $translate('Menu position'), + 'description' => $translate('menu_position_description'), + // @todo Fix the positing calculation in js. For now reload is required. + // 'css' => [ + // [ + // 'selector' => '.bt-sections-menu > ul > li', + // 'property' => 'position', + // 'important' => true + // ] + // ] + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => '"Arial black", Gadget', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li', + 'property' => 'font-family', + ], ], ], - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li a:link', - 'property' => 'color', - ], - [ - 'selector' => '.bt-sections-menu > ul > li a:visited', - 'property' => 'color', - ], - [ - 'selector' => '#pageEntries .xEntry .xGalleryContainer ul.xGalleryNav a', - 'property' => 'color', + 'fontSize' => [ + 'format' => 'text', + 'default' => '20px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li', + 'property' => 'font-size', + ], ], ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color when hovered'), - 'description' => I18n::_('Color of the element under mouse cursor'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li a:hover', - 'property' => 'color', - ], - [ - 'selector' => '.bt-sections-menu > ul > li a:active', - 'property' => 'color', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', - 'property' => 'color', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color when selected'), - 'description' => I18n::_('Color of the element of the currently opened section'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li.selected > a', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', - 'property' => 'color', + 'default' => 'bold', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li', + 'property' => 'font-weight', + ], ], ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li a:link', - 'property' => 'text-decoration', - ], - [ - 'selector' => '.bt-sections-menu > ul > li a:visited', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#pageEntries .xEntry .xGalleryContainer ul.xGalleryNav a', - 'property' => 'text-decoration', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li a:hover', - 'property' => 'text-decoration', - ], - [ - 'selector' => '.bt-sections-menu > ul > li a:active', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li', + 'property' => 'font-style', + ], ], ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'line-through', - 'title' => I18n::_('Decoration when selected'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu > ul > li.selected > a', - 'property' => 'text-decoration', - 'important' => true, - ], - [ - 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', - 'property' => 'text-decoration', + 'lineHeight' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li', + 'property' => 'line-height', + ], + ], + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li a:link', + 'property' => 'color', + ], + [ + 'selector' => '.bt-sections-menu > ul > li a:visited', + 'property' => 'color', + ], + [ + 'selector' => '#pageEntries .xEntry .xGalleryContainer ul.xGalleryNav a', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color when hovered'), + 'description' => $translate('Color of the element under mouse cursor'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li a:hover', + 'property' => 'color', + ], + [ + 'selector' => '.bt-sections-menu > ul > li a:active', + 'property' => 'color', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color when selected'), + 'description' => $translate('Color of the element of the currently opened section'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li.selected > a', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li a:link', + 'property' => 'text-decoration', + ], + [ + 'selector' => '.bt-sections-menu > ul > li a:visited', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#pageEntries .xEntry .xGalleryContainer ul.xGalleryNav a', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li a:hover', + 'property' => 'text-decoration', + ], + [ + 'selector' => '.bt-sections-menu > ul > li a:active', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'line-through', + 'title' => $translate('Decoration when selected'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu > ul > li.selected > a', + 'property' => 'text-decoration', + 'important' => true, + ], + [ + 'selector' => '#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', + 'property' => 'text-decoration', + ], ], ], ], - ], - 'tagsMenu' => [ - '_' => [ - 'title' => I18n::_('Submenu'), - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => '"Arial black", Gadget', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '16px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li', - 'property' => 'font-size', - ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li', - 'property' => 'font-weight', - ], - ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li', - 'property' => 'font-style', - ], - ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '1.5em', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li', - 'property' => 'line-height', + 'tagsMenu' => [ + '_' => [ + 'title' => $translate('Submenu'), + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => '"Arial black", Gadget', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li', + 'property' => 'font-family', + ], ], ], - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li a:link', - 'property' => 'color', - ], - [ - 'selector' => '.bt-sections-menu .subMenu li a:visited', - 'property' => 'color', + 'fontSize' => [ + 'format' => 'text', + 'default' => '16px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li', + 'property' => 'font-size', + ], ], ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li a:hover', - 'property' => 'color', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - [ - 'selector' => '.bt-sections-menu .subMenu li a:active', - 'property' => 'color', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li', + 'property' => 'font-weight', + ], ], ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color when selected'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li.selected > a', - 'property' => 'color', - 'important' => true, + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li a:link', - 'property' => 'text-decoration', - ], - [ - 'selector' => '.bt-sections-menu .subMenu li a:visited', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li', + 'property' => 'font-style', + ], ], ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li a:hover', - 'property' => 'text-decoration', - ], - [ - 'selector' => '.bt-sections-menu .subMenu li a:active', - 'property' => 'text-decoration', + 'lineHeight' => [ + 'format' => 'text', + 'default' => '1.5em', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li', + 'property' => 'line-height', + ], + ], + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li a:link', + 'property' => 'color', + ], + [ + 'selector' => '.bt-sections-menu .subMenu li a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li a:hover', + 'property' => 'color', + ], + [ + 'selector' => '.bt-sections-menu .subMenu li a:active', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color when selected'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li.selected > a', + 'property' => 'color', + 'important' => true, + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li a:link', + 'property' => 'text-decoration', + ], + [ + 'selector' => '.bt-sections-menu .subMenu li a:visited', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li a:hover', + 'property' => 'text-decoration', + ], + [ + 'selector' => '.bt-sections-menu .subMenu li a:active', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Decoration when selected'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu li.selected > a', + 'property' => 'text-decoration', + 'important' => true, + ], + ], + ], + 'x' => [ + 'format' => 'text', + 'css_units' => true, + 'default' => '0', + 'allow_blank' => true, + 'title' => $translate('Positon X'), + 'description' => $translate('tags_menu_x_description'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu', + 'property' => 'left', + ], ], ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Decoration when selected'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu li.selected > a', - 'property' => 'text-decoration', - 'important' => true, + 'y' => [ + 'format' => 'text', + 'css_units' => true, + 'default' => '0', + 'allow_blank' => true, + 'title' => $translate('Positon Y'), + 'description' => $translate('tags_menu_y_description'), + 'css' => [ + [ + 'selector' => '.bt-sections-menu .subMenu', + 'property' => 'top', + ], ], ], - ], - 'x' => [ - 'format' => 'text', - 'css_units' => true, - 'default' => '0', - 'allow_blank' => true, - 'title' => I18n::_('Positon X'), - 'description' => I18n::_('description_tagsMenu_x'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu', - 'property' => 'left', + 'alwaysOpen' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'yes', + 'title' => $translate('Submenu is allways open'), + 'description' => $translate('tags_menu_always_open_description'), ], - ], - 'y' => [ - 'format' => 'text', - 'css_units' => true, - 'default' => '0', - 'allow_blank' => true, - 'title' => I18n::_('Positon Y'), - 'description' => I18n::_('description_tagsMenu_y'), - 'css' => [ - [ - 'selector' => '.bt-sections-menu .subMenu', - 'property' => 'top', + 'hidden' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Submenu is hidden'), + 'description' => '', ], ], - 'alwaysOpen' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'yes', - 'title' => I18n::_('Submenu is allways open'), - 'description' => I18n::_('description_submenu_alwaysopen'), - ], - 'hidden' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Submenu is hidden'), - 'description' => '', - ], - ], - 'entryHeading' => [ - '_' => [ - 'title' => I18n::_('Entry heading'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#363636', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'color', - ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptions, - 'default' => 'Arial, sans-serif', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'font-family', - ], - [ - 'selector' => '#shoppingCartTitle', - 'property' => 'font-family', - ], - [ - 'selector' => '#shoppingCartEmpty', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'font-family', - ], - [ - 'selector' => '#shoppingCartTitle', - 'property' => 'font-family', - ], - [ - 'selector' => '#shoppingCartEmpty', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '1.8em', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'font-size', + 'entryHeading' => [ + '_' => [ + 'title' => $translate('Entry heading'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#363636', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptions, + 'default' => 'Arial, sans-serif', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'font-family', + ], + [ + 'selector' => '#shoppingCartTitle', + 'property' => 'font-family', + ], + [ + 'selector' => '#shoppingCartEmpty', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'font-family', + ], + [ + 'selector' => '#shoppingCartTitle', + 'property' => 'font-family', + ], + [ + 'selector' => '#shoppingCartEmpty', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '1.8em', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'font-size', + ], ], ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'font-weight', - ], - [ - 'selector' => '#shoppingCartTitle', - 'property' => 'font-weight', - ], - [ - 'selector' => '#shoppingCartEmpty', - 'property' => 'font-weight', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', + ], + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'font-weight', + ], + [ + 'selector' => '#shoppingCartTitle', + 'property' => 'font-weight', + ], + [ + 'selector' => '#shoppingCartEmpty', + 'property' => 'font-weight', + ], + ], + ], + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', + ], + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'font-style', + ], + [ + 'selector' => '#shoppingCartTitle', + 'property' => 'font-style', + ], + [ + 'selector' => '#shoppingCartEmpty', + 'property' => 'font-style', + ], + ], + ], + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'font-variant', + ], + [ + 'selector' => '#shoppingCartTitle', + 'property' => 'font-variant', + ], + [ + 'selector' => '#shoppingCartEmpty', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'normal', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'line-height', + ], ], ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'font-style', - ], - [ - 'selector' => '#shoppingCartTitle', - 'property' => 'font-style', - ], - [ - 'selector' => '#shoppingCartEmpty', - 'property' => 'font-style', + 'margin' => [ + 'format' => 'text', + 'default' => '15px 0 15px 0', + 'title' => $translate('Margins'), + 'description' => $translate('How far the entry heading is form other elements in page.'), + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry h2', + 'property' => 'margin', + ], ], ], ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', + + 'entryLayout' => [ + '_' => [ + 'title' => $translate('Entry layout'), ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'font-variant', - ], - [ - 'selector' => '#shoppingCartTitle', - 'property' => 'font-variant', - ], - [ - 'selector' => '#shoppingCartEmpty', - 'property' => 'font-variant', + 'contentWidth' => [ + 'format' => 'text', + 'default' => '400px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Entry text max width'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry', + 'property' => 'max-width', + ], + [ + 'selector' => '#xBackground .visual-caption', + 'property' => 'width', + ], + ], + ], + 'defaultGalleryType' => [ + 'format' => 'select', + 'values' => [ + 'slideshow', + 'row', + 'grid', ], + 'default' => 'slideshow', + 'title' => $translate('Default gallery type'), + 'description' => $translate('Slideshow means that an image menu plus only one image is visible at a time. Row means that all images are visible.'), ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'normal', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'line-height', + 'spaceBetweenImages' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images in row and column'), + 'description' => $translate('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry .xGalleryType-column .xGalleryItem', + 'property' => 'padding-bottom', + ], + [ + 'selector' => '#pageEntries .xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', + 'property' => 'margin-right', + ], + [ + 'selector' => '.bt-responsive #pageEntries .xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', + 'property' => 'padding-bottom', + 'breakpoint' => '(max-width: 767px)', + ], + [ + 'selector' => '.bt-auto-responsive #pageEntries .xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', + 'property' => 'padding-bottom', + 'breakpoint' => '(max-width: 767px)', + ], + ], + ], + 'galleryNavMargin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images and image navigation'), + 'description' => $translate('Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode'), + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry .xGalleryType-slideshow .xGallery', + 'property' => 'margin-bottom', + ], ], ], - ], - 'margin' => [ - 'format' => 'text', - 'default' => '15px 0 15px 0', - 'title' => I18n::_('Margins'), - 'description' => I18n::_('How far the entry heading is form other elements in page.'), - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry h2', - 'property' => 'margin', + 'galleryMargin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Empty space below gallery'), + 'description' => $translate('Distance between the gallery and the content below'), + 'css' => [ + [ + 'selector' => '#pageEntries .xEntry .xGalleryContainer', + 'property' => 'margin-bottom', + ], ], ], ], - ], - 'entryLayout' => [ - '_' => [ - 'title' => I18n::_('Entry layout'), - ], - 'contentWidth' => [ - 'format' => 'text', - 'default' => '400px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Entry text max width'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry', - 'property' => 'max-width', - ], - [ - 'selector' => '#xBackground .visual-caption', - 'property' => 'width', - ], - ], - ], - 'defaultGalleryType' => [ - 'format' => 'select', - 'values' => [ - 'slideshow', - 'row', - 'grid', - ], - 'default' => 'slideshow', - 'title' => I18n::_('Default gallery type'), - 'description' => I18n::_('Slideshow means that an image menu plus only one image is visible at a time. Row means that all images are visible.'), - ], - 'spaceBetweenImages' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images in row and column'), - 'description' => I18n::_('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry .xGalleryType-column .xGalleryItem', - 'property' => 'padding-bottom', - ], - [ - 'selector' => '#pageEntries .xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', - 'property' => 'margin-right', - ], - [ - 'selector' => '.bt-responsive #pageEntries .xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', - 'property' => 'padding-bottom', - 'breakpoint' => '(max-width: 767px)', - ], - [ - 'selector' => '.bt-auto-responsive #pageEntries .xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', - 'property' => 'padding-bottom', - 'breakpoint' => '(max-width: 767px)', - ], - ], - ], - 'galleryNavMargin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images and image navigation'), - 'description' => I18n::_('Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode'), - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry .xGalleryType-slideshow .xGallery', - 'property' => 'margin-bottom', + 'additionalText' => [ + '_' => [ + 'title' => $translate('Additional text'), + ], + 'color' => [ + 'format' => 'color', + 'default' => 'inherit', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], ], ], - ], - 'galleryMargin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Empty space below gallery'), - 'description' => I18n::_('Distance between the gallery and the content below'), - 'css' => [ - [ - 'selector' => '#pageEntries .xEntry .xGalleryContainer', - 'property' => 'margin-bottom', + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-size', + ], ], ], ], - ], - 'additionalText' => [ - '_' => [ - 'title' => I18n::_('Additional text'), - ], - 'color' => [ - 'format' => 'color', - 'default' => 'inherit', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'color', - ], + 'socialMediaLinks' => [ + '_' => [ + 'title' => $translate('Social media buttons'), ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Button color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.social-icon path', + 'property' => 'fill', + ], ], ], ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', - ], + + 'css' => [ + '_' => [ + 'title' => $translate('Custom CSS'), ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-size', - ], + 'customCSS' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Custom CSS'), + 'description' => $translate('custom_css_description'), ], ], - ], - 'socialMediaLinks' => [ - '_' => [ - 'title' => I18n::_('Social media buttons'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Button color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.social-icon path', - 'property' => 'fill', - ], + 'settings' => [ + '_' => [ + 'title' => $translate('Other settings'), + ], + 'resetTemplate' => [ + 'format' => 'action', + 'default' => 'resetTemplate', + 'title' => $translate('Reset design'), + 'description' => $translate('Reset design to default values.'), ], ], - ], + ]; - 'css' => [ - '_' => [ - 'title' => I18n::_('Custom CSS'), - ], - 'customCSS' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Custom CSS'), - 'description' => I18n::_('description_custom_css'), - ], - ], + $shopTemplateConfPath = dirname(__DIR__, 2) . '/_plugin_shop/template.conf.php'; + if ($isShopEnabled && is_file($shopTemplateConfPath)) { + $sectionTypes += (require $shopTemplateConfPath)($translate); + } - 'settings' => [ - '_' => [ - 'title' => I18n::_('Other settings'), - ], - 'resetTemplate' => [ - 'format' => 'action', - 'default' => 'resetTemplate', - 'title' => I18n::_('Reset design'), - 'description' => I18n::_('Reset design to default values.'), + $translations = [ + 'sectionTypes' => [ + 'type' => $translate('section_type'), + 'type_tip' => $translate('section_type_tip'), ], - ], -]; - -if (@file_exists('../_plugin_shop/template.conf.php')) { - include '../_plugin_shop/template.conf.php'; -} - -$translations = [ - 'sectionTypes' => [ - 'type' => I18n::_('section_type'), - 'type_tip' => I18n::_('section_type_tip'), - ], -]; + ]; -return [$sectionTypes, $templateConf, $translations]; + return [$sectionTypes, $templateConf, $translations]; +}; diff --git a/_templates/white-0.3.5/template.conf.php b/_templates/white-0.3.5/template.conf.php index 2d58cfe95..a92aa2fad 100644 --- a/_templates/white-0.3.5/template.conf.php +++ b/_templates/white-0.3.5/template.conf.php @@ -1,1030 +1,1019 @@ 'Arial, sans-serif', - 'Helvetica, Arial, sans-serif' => 'Helvetica, Arial, sans-serif', - '"Helvetica Neue", Helvetica, Arial, sans-serif' => 'Helvetica Neue, Helvetica, Arial, sans-serif', - '"Arial Black", Gadget, sans-serif' => 'Arial Black, Gadget', - '"Comic Sans MS", cursive' => 'Comic Sans MS', - '"Courier New", Courier, monospace' => 'Courier New, Courier', - 'Georgia, "Times New Roman", Times, serif' => 'Georgia, Times New Roman, Times', - 'Impact, Charcoal, sans-serif' => 'Impact, Charcoal', - '"Lucida Console", Monaco, monospace' => 'Lucida Console, Monaco', - '"Lucida Sans Unicode", "Lucida Grande", sans-serif' => 'Lucida Sans Unicode, Lucida Grande', - '"Palatino Linotype", "Book Antiqua", Palatino, serif' => 'Palatino Linotype, Book Antiqua, Palatino', - 'Tahoma, Geneva, sans-serif' => 'Tahoma, Geneva', - '"Times New Roman", Times, serif' => 'Times New Roman, Times', - '"Trebuchet MS", Helvetica, sans-serif' => 'Trebuchet MS, Helvetica', - 'Verdana, Geneva, sans-serif' => 'Verdana, Geneva', -]; -$fontOptionsWithInherit = array_merge([ - 'inherit' => '(inherit from general-font-settings)', -], $fontOptions); +/** + * The template's section types and settings: their default values for the site engine, and their titles and + * descriptions for the editor. `$translate` translates a title or description into the editor's language. + * + * @return Closure(callable(string): string, bool): array{0: array, 1: array, 2: array} + */ +return static function (callable $translate, bool $isShopEnabled): array { + ['fonts' => $fontOptions, 'withInherit' => $fontOptionsWithInherit] = require dirname(__DIR__, 2) . '/engine/inc.font-options.php'; -$sectionTypes = [ - 'default' => [ - 'title' => 'Default', - ], - 'external_link' => [ - 'title' => 'External link', - 'params' => [ - 'link' => [ - 'format' => 'url', - 'default' => '', - 'allow_blank' => true, - 'link' => true, - ], - 'target' => [ - 'format' => 'select', - 'values' => [ - '_self' => 'Same window', - '_blank' => 'New window', - ], - 'default' => '_blank', - ], - ], - ], - 'portfolio' => [ - 'title' => 'Portfolio', - ], -]; - -$templateConf = [ - 'generalFontSettings' => [ - '_' => [ - 'title' => I18n::_('General font settings'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'color', - ], - ], + $sectionTypes = [ + 'default' => [ + 'title' => 'Default', ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptions, - 'default' => 'Arial, sans-serif', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', + 'external_link' => [ + 'title' => 'External link', + 'params' => [ + 'link' => [ + 'format' => 'url', + 'default' => '', + 'allow_blank' => true, + 'link' => true, ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '11px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-size', - ], - ], - ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', - ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-weight', + 'target' => [ + 'format' => 'select', + 'values' => [ + '_self' => 'Same window', + '_blank' => 'New window', + ], + 'default' => '_blank', ], ], ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-style', - ], - ], + 'portfolio' => [ + 'title' => 'Portfolio', ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'font-variant', - ], - ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => 'normal', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'line-height', - ], - ], - ], - ], + ]; - 'links' => [ - '_' => [ - 'title' => I18n::_('Hyperlinks'), - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'color', - ], - ], - ], - 'colorVisited' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Visited link color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'color', - ], - ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Link color when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'color', + $templateConf = [ + 'generalFontSettings' => [ + '_' => [ + 'title' => $translate('General font settings'), + ], + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptions, + 'default' => 'Arial, sans-serif', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '11px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-size', + ], ], ], - ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Link color when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'color', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:link', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-weight', + ], ], ], - ], - 'textDecorationVisited' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Visited link decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:visited', - 'property' => 'text-decoration', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:hover', - 'property' => 'text-decoration', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-style', + ], ], ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Link decoration when clicked'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'a:active', - 'property' => 'text-decoration', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => 'normal', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'line-height', + ], ], ], ], - ], - 'background' => [ - '_' => [ - 'title' => I18n::_('Background'), - ], - 'backgroundColor' => [ - 'format' => 'color', - 'default' => '#FFFFFF', - 'title' => I18n::_('Background color'), - 'description' => '', - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-color', - ], - ], - ], - 'backgroundImageEnabled' => [ - 'format' => 'select', - 'values' => [ - 'yes', - 'no', - ], - 'default' => 'no', - 'title' => I18n::_('Is background image enabled?'), - 'description' => '', - ], - 'backgroundImage' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'affectsStyle' => true, - 'title' => I18n::_('Background image'), - 'description' => I18n::_('Picture to use for page background.'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-image', + 'links' => [ + '_' => [ + 'title' => $translate('Hyperlinks'), + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'color', + ], + ], + ], + 'colorVisited' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Visited link color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Link color when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Link color when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'color', + ], + ], + ], + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:link', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationVisited' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Visited link decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:visited', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Link decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', ], - ], - ], - 'backgroundRepeat' => [ - 'format' => 'select', - 'values' => [ - 'repeat' => 'tile vertically and horizontally', - 'repeat-x' => 'tile horizontally', - 'repeat-y' => 'tile vertically', - 'no-repeat' => 'no tiling', - ], - 'default' => 'repeat', - 'title' => I18n::_('Background tiling'), - 'description' => I18n::_('How the background fills the screen?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-repeat', + 'default' => 'underline', + 'title' => $translate('Link decoration when clicked'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'a:active', + 'property' => 'text-decoration', + ], ], ], ], - 'backgroundPosition' => [ - 'format' => 'select', - 'values' => [ - 'top left', - 'top center', - 'top right', - 'center left', - 'center', - 'center right', - 'bottom left', - 'bottom center', - 'bottom right', - ], - 'default' => 'top left', - 'title' => I18n::_('Background alignment'), - 'description' => I18n::_('Where the background image is positioned?'), - 'css' => [ - [ - 'selector' => 'body', - 'property' => 'background-position', - ], - ], - ], - 'backgroundAttachment' => [ - 'format' => 'select', - 'values' => [ - 'fixed' => 'Fixed to browser window', - 'fill' => 'Filled in browser window', - 'scroll' => 'No stretch, scroll along with content', - ], - 'default' => 'scroll', - 'title' => I18n::_('Background position'), - 'description' => I18n::_('Sets how background behaves in relation with the browser window.'), - ], - ], - 'pageLayout' => [ - '_' => [ - 'title' => I18n::_('Page layout'), - ], - 'centered' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'yes', - 'no', - ], - 'title' => I18n::_('Centered layout'), - 'description' => I18n::_('Sets whether layout should be centered or not.'), - ], - 'contentWidth' => [ - 'format' => 'text', - 'default' => '600px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Entry text max width'), - 'description' => I18n::_('Width of texts in the entries. This does not apply to the width of images.'), - 'css' => [ - [ - 'selector' => '#mainColumn', - 'property' => 'max-width', - ], - ], - ], - 'paddingTop' => [ - 'format' => 'text', - 'default' => '90px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('How far content is from page top?'), - 'description' => I18n::_('The vertical distance between the top of the page and the content area.'), - 'css' => [ - [ - 'selector' => '#mainColumn', - 'property' => 'padding-top', - ], - ], - ], - 'paddingLeft' => [ - 'format' => 'text', - 'default' => '30px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('How far content is from menu?'), - 'description' => I18n::_('The horizontal distance between the menu and the content area.'), - 'css' => [ - [ - 'selector' => '#mainColumn', - 'property' => 'padding-left', - ], - [ - 'selector' => '#mainColumn', - 'property' => 'padding-right', - ], - [ - 'selector' => '.floating-banners', - 'property' => 'padding-left', - ], - [ - 'selector' => '.bt-responsive #sideColumn', - 'property' => 'padding-left', - 'breakpoint' => '(max-width: 767px)', - ], - [ - 'selector' => '.bt-responsive #sideColumnBottom', - 'property' => 'padding-left', - 'breakpoint' => '(max-width: 767px)', + 'background' => [ + '_' => [ + 'title' => $translate('Background'), + ], + 'backgroundColor' => [ + 'format' => 'color', + 'default' => '#FFFFFF', + 'title' => $translate('Background color'), + 'description' => '', + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-color', + ], + ], + ], + 'backgroundImageEnabled' => [ + 'format' => 'select', + 'values' => [ + 'yes', + 'no', ], + 'default' => 'no', + 'title' => $translate('Is background image enabled?'), + 'description' => '', ], - ], - 'leftColumnWidth' => [ - 'format' => 'text', - 'default' => '170px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Width of the left column'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumn', - 'property' => 'width', - ], - [ - 'selector' => '#mainColumn', - 'property' => 'margin-left', - ], - [ - 'selector' => '.xNarrow #mainColumn.xCentered', - 'property' => 'margin-left', - ], - [ - 'selector' => '.floating-banners', - 'property' => 'margin-left', + 'backgroundImage' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'affectsStyle' => true, + 'title' => $translate('Background image'), + 'description' => $translate('Picture to use for page background.'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-image', + ], + ], + ], + 'backgroundRepeat' => [ + 'format' => 'select', + 'values' => [ + 'repeat' => 'tile vertically and horizontally', + 'repeat-x' => 'tile horizontally', + 'repeat-y' => 'tile vertically', + 'no-repeat' => 'no tiling', + ], + 'default' => 'repeat', + 'title' => $translate('Background tiling'), + 'description' => $translate('How the background fills the screen?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-repeat', + ], + ], + ], + 'backgroundPosition' => [ + 'format' => 'select', + 'values' => [ + 'top left', + 'top center', + 'top right', + 'center left', + 'center', + 'center right', + 'bottom left', + 'bottom center', + 'bottom right', + ], + 'default' => 'top left', + 'title' => $translate('Background alignment'), + 'description' => $translate('Where the background image is positioned?'), + 'css' => [ + [ + 'selector' => 'body', + 'property' => 'background-position', + ], + ], + ], + 'backgroundAttachment' => [ + 'format' => 'select', + 'values' => [ + 'fixed' => 'Fixed to browser window', + 'fill' => 'Filled in browser window', + 'scroll' => 'No stretch, scroll along with content', ], + 'default' => 'scroll', + 'title' => $translate('Background position'), + 'description' => $translate('Sets how background behaves in relation with the browser window.'), ], ], - 'group_responsive' => [ - 'format' => false, - 'default' => false, - 'title' => I18n::_('Responsive design'), - ], - 'responsive' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'no', - 'yes', - ], - 'title' => I18n::_('Enabled'), - 'description' => I18n::_('Sets whether layout should be responsive or not.'), - ], - ], - 'pageHeading' => [ - '_' => [ - 'title' => I18n::_('Page heading'), - ], - 'image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 140, - 'max_height' => 400, - 'title' => I18n::_('Header image'), - 'description' => '' . I18n::_('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop h1 a', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => '#sideColumnTop h1 a:link', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => '#sideColumnTop h1 a:visited', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => '#sideColumnTop h1 a:hover', - 'property' => 'color', - 'important' => true, - ], - [ - 'selector' => '#sideColumnTop h1 a:active', - 'property' => 'color', - 'important' => true, - ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-family', - ], + 'pageLayout' => [ + '_' => [ + 'title' => $translate('Page layout'), ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => 'Google web fonts', - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-family', + 'centered' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'yes', + 'no', ], + 'title' => $translate('Centered layout'), + 'description' => $translate('Sets whether layout should be centered or not.'), ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => '20px', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-size', + 'contentWidth' => [ + 'format' => 'text', + 'default' => '600px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Entry text max width'), + 'description' => $translate('Width of texts in the entries. This does not apply to the width of images.'), + 'css' => [ + [ + 'selector' => '#mainColumn', + 'property' => 'max-width', + ], + ], + ], + 'paddingTop' => [ + 'format' => 'text', + 'default' => '90px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('How far content is from page top?'), + 'description' => $translate('The vertical distance between the top of the page and the content area.'), + 'css' => [ + [ + 'selector' => '#mainColumn', + 'property' => 'padding-top', + ], + ], + ], + 'paddingLeft' => [ + 'format' => 'text', + 'default' => '30px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('How far content is from menu?'), + 'description' => $translate('The horizontal distance between the menu and the content area.'), + 'css' => [ + [ + 'selector' => '#mainColumn', + 'property' => 'padding-left', + ], + [ + 'selector' => '#mainColumn', + 'property' => 'padding-right', + ], + [ + 'selector' => '.floating-banners', + 'property' => 'padding-left', + ], + [ + 'selector' => '.bt-responsive #sideColumn', + 'property' => 'padding-left', + 'breakpoint' => '(max-width: 767px)', + ], + [ + 'selector' => '.bt-responsive #sideColumnBottom', + 'property' => 'padding-left', + 'breakpoint' => '(max-width: 767px)', + ], + ], + ], + 'leftColumnWidth' => [ + 'format' => 'text', + 'default' => '170px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Width of the left column'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumn', + 'property' => 'width', + ], + [ + 'selector' => '#mainColumn', + 'property' => 'margin-left', + ], + [ + 'selector' => '.xNarrow #mainColumn.xCentered', + 'property' => 'margin-left', + ], + [ + 'selector' => '.floating-banners', + 'property' => 'margin-left', + ], + ], + ], + 'group_responsive' => [ + 'format' => false, + 'default' => false, + 'title' => $translate('Responsive design'), + ], + 'responsive' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'no', + 'yes', ], + 'title' => $translate('Enabled'), + 'description' => $translate('Sets whether layout should be responsive or not.'), ], ], - 'fontWeight' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'bold', + + 'pageHeading' => [ + '_' => [ + 'title' => $translate('Page heading'), ], - 'default' => 'normal', - 'title' => I18n::_('Font weight'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-weight', + 'image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 140, + 'max_height' => 400, + 'title' => $translate('Header image'), + 'description' => '' . $translate('Displayed image will be half of the original size, full size will be used for hi-res displays.') . '', + ], + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop h1 a', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => '#sideColumnTop h1 a:link', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => '#sideColumnTop h1 a:visited', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => '#sideColumnTop h1 a:hover', + 'property' => 'color', + 'important' => true, + ], + [ + 'selector' => '#sideColumnTop h1 a:active', + 'property' => 'color', + 'important' => true, + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => 'Google web fonts', + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => '20px', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-size', + ], ], ], - ], - 'fontStyle' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'italic', - ], - 'default' => 'normal', - 'title' => I18n::_('Font style'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-style', + 'fontWeight' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'bold', ], - ], - ], - 'fontVariant' => [ - 'format' => 'select', - 'values' => [ - 'normal', - 'small-caps', - ], - 'default' => 'normal', - 'title' => I18n::_('Font variant'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'font-variant', + 'default' => 'normal', + 'title' => $translate('Font weight'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-weight', + ], ], ], - ], - 'lineHeight' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'title' => I18n::_('Line height'), - 'description' => I18n::_('Height of text line. Use em, px or % values or the default value "normal"'), - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'line-height', + 'fontStyle' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'italic', ], - ], - ], - 'marginTop' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Empty space on top'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'margin-top', + 'default' => 'normal', + 'title' => $translate('Font style'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-style', + ], ], ], - ], - 'marginBottom' => [ - 'format' => 'text', - 'default' => '20px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Empty space on bottom'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop h1', - 'property' => 'margin-bottom', + 'fontVariant' => [ + 'format' => 'select', + 'values' => [ + 'normal', + 'small-caps', + ], + 'default' => 'normal', + 'title' => $translate('Font variant'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'font-variant', + ], + ], + ], + 'lineHeight' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'title' => $translate('Line height'), + 'description' => $translate('Height of text line. Use em, px or % values or the default value "normal"'), + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'line-height', + ], + ], + ], + 'marginTop' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Empty space on top'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'margin-top', + ], + ], + ], + 'marginBottom' => [ + 'format' => 'text', + 'default' => '20px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Empty space on bottom'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop h1', + 'property' => 'margin-bottom', + ], ], ], ], - ], - 'additionalText' => [ - '_' => [ - 'title' => I18n::_('Additional text'), - ], - 'color' => [ - 'format' => 'color', - 'default' => 'inherit', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'color', - ], - ], - ], - 'fontFamily' => [ - 'format' => 'fontselect', - 'values' => $fontOptionsWithInherit, - 'default' => 'inherit', - 'title' => I18n::_('Font face'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', - ], - ], - ], - 'googleFont' => [ - 'format' => 'text', - 'default' => '', - 'allow_blank' => true, - 'html_entities' => true, - 'title' => I18n::_('Google web fonts'), - 'description' => I18n::_('googleFont_description'), - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-family', - ], - ], - ], - 'fontSize' => [ - 'format' => 'text', - 'default' => 'inherit', - 'allow_blank' => true, - 'title' => I18n::_('Font size'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#additionalText', - 'property' => 'font-size', + 'additionalText' => [ + '_' => [ + 'title' => $translate('Additional text'), + ], + 'color' => [ + 'format' => 'color', + 'default' => 'inherit', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'color', + ], + ], + ], + 'fontFamily' => [ + 'format' => 'fontselect', + 'values' => $fontOptionsWithInherit, + 'default' => 'inherit', + 'title' => $translate('Font face'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], + ], + ], + 'googleFont' => [ + 'format' => 'text', + 'default' => '', + 'allow_blank' => true, + 'html_entities' => true, + 'title' => $translate('Google web fonts'), + 'description' => $translate('google_font_description'), + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-family', + ], + ], + ], + 'fontSize' => [ + 'format' => 'text', + 'default' => 'inherit', + 'allow_blank' => true, + 'title' => $translate('Font size'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#additionalText', + 'property' => 'font-size', + ], ], ], ], - ], - 'menu' => [ - '_' => [ - 'title' => I18n::_('Main menu'), - ], - 'colorLink' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop a:link', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop a:visited', - 'property' => 'color', - ], - [ - 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a', - 'property' => 'color', + 'menu' => [ + '_' => [ + 'title' => $translate('Main menu'), + ], + 'colorLink' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop a:link', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop a:visited', + 'property' => 'color', + ], + [ + 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a', + 'property' => 'color', + ], + ], + ], + 'colorHover' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Color when hovered'), + 'description' => $translate('Color of the element under mouse cursor'), + 'css' => [ + [ + 'selector' => '#sideColumnTop a:hover', + 'property' => 'color', + ], + [ + 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', + 'property' => 'color', + ], + ], + ], + 'colorActive' => [ + 'format' => 'color', + 'default' => '#666666', + 'title' => $translate('Color when selected'), + 'description' => $translate('Color of the element of the currently opened section'), + 'css' => [ + [ + 'selector' => '#sideColumnTop a:active', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop li.selected > a', + 'property' => 'color', + ], + [ + 'selector' => '#sideColumnTop li.selected > span', + 'property' => 'color', + ], + + [ + 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', + 'property' => 'color', + ], ], ], - ], - 'colorHover' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Color when hovered'), - 'description' => I18n::_('Color of the element under mouse cursor'), - 'css' => [ - [ - 'selector' => '#sideColumnTop a:hover', - 'property' => 'color', - ], - [ - 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', - 'property' => 'color', + 'textDecorationLink' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'none', + 'title' => $translate('Decoration'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop a:link', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop a:visited', + 'property' => 'text-decoration', + ], + [ + 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationHover' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Decoration when hovered'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop a:hover', + 'property' => 'text-decoration', + ], + [ + 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', + 'property' => 'text-decoration', + ], + ], + ], + 'textDecorationActive' => [ + 'format' => 'select', + 'values' => [ + 'none', + 'underline', + 'overline', + 'line-through', + ], + 'default' => 'underline', + 'title' => $translate('Decoration when selected'), + 'description' => '', + 'css' => [ + [ + 'selector' => '#sideColumnTop a:active', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop li.selected > a', + 'property' => 'text-decoration', + ], + [ + 'selector' => '#sideColumnTop li.selected > span', + 'property' => 'text-decoration', + ], + [ + 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', + 'property' => 'text-decoration', + ], ], ], ], - 'colorActive' => [ - 'format' => 'color', - 'default' => '#666666', - 'title' => I18n::_('Color when selected'), - 'description' => I18n::_('Color of the element of the currently opened section'), - 'css' => [ - [ - 'selector' => '#sideColumnTop a:active', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop li.selected > a', - 'property' => 'color', - ], - [ - 'selector' => '#sideColumnTop li.selected > span', - 'property' => 'color', - ], - [ - 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', - 'property' => 'color', - ], + 'entryLayout' => [ + '_' => [ + 'title' => $translate('Entry layout'), ], - ], - 'textDecorationLink' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'none', - 'title' => I18n::_('Decoration'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop a:link', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop a:visited', - 'property' => 'text-decoration', - ], - [ - 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a', - 'property' => 'text-decoration', + 'spaceBetween' => [ + 'format' => 'text', + 'default' => '20px', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between entries'), + 'description' => $translate('Distance from entry to entry. In pixels.'), + 'css' => [ + [ + 'selector' => 'ol#pageEntries li.xEntry', + 'property' => 'margin-bottom', + ], ], ], - ], - 'textDecorationHover' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Decoration when hovered'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop a:hover', - 'property' => 'text-decoration', - ], - [ - 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li a:hover', - 'property' => 'text-decoration', + 'defaultGalleryType' => [ + 'format' => 'select', + 'values' => [ + 'slideshow', + 'row', + 'grid', ], + 'default' => 'slideshow', + 'title' => $translate('Default gallery type'), + 'description' => '', ], - ], - 'textDecorationActive' => [ - 'format' => 'select', - 'values' => [ - 'none', - 'underline', - 'overline', - 'line-through', - ], - 'default' => 'underline', - 'title' => I18n::_('Decoration when selected'), - 'description' => '', - 'css' => [ - [ - 'selector' => '#sideColumnTop a:active', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop li.selected > a', - 'property' => 'text-decoration', - ], - [ - 'selector' => '#sideColumnTop li.selected > span', - 'property' => 'text-decoration', - ], - [ - 'selector' => 'ol#pageEntries .xGalleryContainer ul.xGalleryNav li.selected a', - 'property' => 'text-decoration', + 'spaceBetweenImages' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images in row and column'), + 'description' => $translate('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), + 'css' => [ + [ + 'selector' => 'ol#pageEntries li.xEntry .xGalleryType-column .xGalleryItem', + 'property' => 'padding-bottom', + ], + [ + 'selector' => 'ol#pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', + 'property' => 'margin-right', + ], + [ + 'selector' => '.bt-responsive ol#pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', + 'property' => 'padding-bottom', + ], + ], + ], + 'galleryNavMargin' => [ + 'format' => 'text', + 'default' => '0', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Space between images and image navigation'), + 'description' => $translate('Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode'), + 'css' => [ + [ + 'selector' => 'ol#pageEntries li.xEntry .xGalleryType-slideshow .xGallery', + 'property' => 'margin-bottom', + ], + ], + ], + 'galleryMargin' => [ + 'format' => 'text', + 'default' => '1em', + 'allow_blank' => true, + 'css_units' => true, + 'title' => $translate('Empty space below gallery'), + 'description' => $translate('Distance between the gallery and the content below'), + 'css' => [ + [ + 'selector' => 'ol#pageEntries li.xEntry .xGalleryContainer', + 'property' => 'margin-bottom', + ], ], ], ], - ], - 'entryLayout' => [ - '_' => [ - 'title' => I18n::_('Entry layout'), - ], - 'spaceBetween' => [ - 'format' => 'text', - 'default' => '20px', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between entries'), - 'description' => I18n::_('Distance from entry to entry. In pixels.'), - 'css' => [ - [ - 'selector' => 'ol#pageEntries li.xEntry', - 'property' => 'margin-bottom', - ], + 'socialMediaLinks' => [ + '_' => [ + 'title' => $translate('Social media buttons'), ], - ], - 'defaultGalleryType' => [ - 'format' => 'select', - 'values' => [ - 'slideshow', - 'row', - 'grid', - ], - 'default' => 'slideshow', - 'title' => I18n::_('Default gallery type'), - 'description' => '', - ], - 'spaceBetweenImages' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images in row and column'), - 'description' => I18n::_('Horizontal/vertical space between images when gallery is in "row"/"column" mode'), - 'css' => [ - [ - 'selector' => 'ol#pageEntries li.xEntry .xGalleryType-column .xGalleryItem', - 'property' => 'padding-bottom', - ], - [ - 'selector' => 'ol#pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGalleryItem', - 'property' => 'margin-right', - ], - [ - 'selector' => '.bt-responsive ol#pageEntries li.xEntry .xGalleryType-row:not(.bt-gallery-has-one-item) .xGallery .xGalleryItem', - 'property' => 'padding-bottom', + 'color' => [ + 'format' => 'color', + 'default' => '#000000', + 'title' => $translate('Button color'), + 'description' => '', + 'css' => [ + [ + 'selector' => '.social-icon path', + 'property' => 'fill', + ], ], ], ], - 'galleryNavMargin' => [ - 'format' => 'text', - 'default' => '0', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Space between images and image navigation'), - 'description' => I18n::_('Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode'), - 'css' => [ - [ - 'selector' => 'ol#pageEntries li.xEntry .xGalleryType-slideshow .xGallery', - 'property' => 'margin-bottom', - ], + + 'css' => [ + '_' => [ + 'title' => $translate('Custom CSS'), ], - ], - 'galleryMargin' => [ - 'format' => 'text', - 'default' => '1em', - 'allow_blank' => true, - 'css_units' => true, - 'title' => I18n::_('Empty space below gallery'), - 'description' => I18n::_('Distance between the gallery and the content below'), - 'css' => [ - [ - 'selector' => 'ol#pageEntries li.xEntry .xGalleryContainer', - 'property' => 'margin-bottom', - ], + 'customCSS' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Custom CSS'), + 'description' => $translate('custom_css_description'), ], ], - ], - 'socialMediaLinks' => [ - '_' => [ - 'title' => I18n::_('Social media buttons'), - ], - 'color' => [ - 'format' => 'color', - 'default' => '#000000', - 'title' => I18n::_('Button color'), - 'description' => '', - 'css' => [ - [ - 'selector' => '.social-icon path', - 'property' => 'fill', - ], + 'settings' => [ + '_' => [ + 'title' => $translate('Other settings'), + ], + 'resetTemplate' => [ + 'format' => 'action', + 'default' => 'resetTemplate', + 'title' => $translate('Reset design'), + 'description' => $translate('Reset design to default values.'), ], ], - ], - - 'css' => [ - '_' => [ - 'title' => I18n::_('Custom CSS'), - ], - 'customCSS' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Custom CSS'), - 'description' => I18n::_('description_custom_css'), - ], - ], + ]; - 'settings' => [ - '_' => [ - 'title' => I18n::_('Other settings'), + $translations = [ + 'sectionTypes' => [ + 'type' => $translate('section_type'), + 'type_tip' => $translate('section_type_tip'), ], - 'resetTemplate' => [ - 'format' => 'action', - 'default' => 'resetTemplate', - 'title' => I18n::_('Reset design'), - 'description' => I18n::_('Reset design to default values.'), - ], - ], -]; - -$translations = [ - 'sectionTypes' => [ - 'type' => I18n::_('section_type'), - 'type_tip' => I18n::_('section_type_tip'), - ], -]; + ]; -return [$sectionTypes, $templateConf, $translations]; + return [$sectionTypes, $templateConf, $translations]; +}; 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/app.module.ts b/editor/src/app/app.module.ts index 70cbcce07..ff422f011 100644 --- a/editor/src/app/app.module.ts +++ b/editor/src/app/app.module.ts @@ -2,6 +2,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule, ErrorHandler, APP_INITIALIZER } from '@angular/core'; import { provideHttpClient, + withInterceptors, withInterceptorsFromDi, } from '@angular/common/http'; import { APP_BASE_HREF } from '@angular/common'; @@ -48,6 +49,7 @@ import { SentryConfigService } from './sentry/sentry-config.service'; import * as Sentry from '@sentry/angular'; import { Router } from '@angular/router'; import { sentryInitFactory } from './sentry/sentry-init.factory'; +import { pendingRequestsInterceptor } from './shared/pending-requests'; @NgModule({ declarations: [ @@ -118,7 +120,10 @@ import { sentryInitFactory } from './sentry/sentry-init.factory'; MashupTemplateStyleService, MessyTemplateStyleService, { provide: APP_BASE_HREF, useValue: '/engine/' }, - provideHttpClient(withInterceptorsFromDi()), + provideHttpClient( + withInterceptorsFromDi(), + withInterceptors([pendingRequestsInterceptor]), + ), ], }) export class AppModule {} diff --git a/editor/src/app/header/header.component.ts b/editor/src/app/header/header.component.ts index 3c6860d64..7506aee54 100644 --- a/editor/src/app/header/header.component.ts +++ b/editor/src/app/header/header.component.ts @@ -2,6 +2,11 @@ import { Component } from '@angular/core'; import { Store } from '@ngxs/store'; import { Observable } from 'rxjs'; import { AppState } from '../app-state/app.state'; +import { delayedIndicator } from '../shared/delayed-indicator'; +import { + PendingRequestsService, + whileRequestsPending, +} from '../shared/pending-requests'; import { UserState } from '../user/user.state'; import { UserStateModel } from '../user/user.state.model'; import { AiAssistantState } from '../ai-assistant/ai-assistant.state'; @@ -11,10 +16,7 @@ import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; selector: 'berta-header', template: `
            -
            +
            @if (isLoggedIn$ | async) {
            @if (!(isSetup$ | async)) { @@ -89,7 +91,6 @@ import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; } header .loading { - display: none; content: ''; position: absolute; width: 100%; @@ -106,6 +107,17 @@ import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; background-size: 200% 200%; animation: barberpole 15s linear infinite; z-index: 1; + opacity: 0; + visibility: hidden; + transition: + opacity 0.2s, + visibility 0s 0.2s; + } + + header .loading.bt-shown { + opacity: 1; + visibility: visible; + transition: opacity 0.2s; } @keyframes barberpole { @@ -146,14 +158,23 @@ import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; export class HeaderComponent { user$: Observable; isLoggedIn$: Observable; - isLoading$: Observable; + /** + * A quick load shows no loading bar, so it doesn't flash. Once on, the bar stays until the last + * request has ended. + */ + showsLoading$: Observable; isSetup$: Observable; isAiOpen$: Observable; - constructor(private store: Store) { + constructor( + private store: Store, + pendingRequests: PendingRequestsService, + ) { this.user$ = this.store.select((state) => state.user); this.isLoggedIn$ = this.store.select(UserState.isLoggedIn); - this.isLoading$ = this.store.select(AppState.getShowLoading); + this.showsLoading$ = this.store + .select(AppState.getShowLoading) + .pipe(whileRequestsPending(pendingRequests.count$), delayedIndicator()); this.isSetup$ = this.store.select(AppState.isSetup); this.isAiOpen$ = this.store.select(AiAssistantState.isOpen); } 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/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/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..b0e05a704 100644 --- a/editor/src/app/render/twig-templates.ts +++ b/editor/src/app/render/twig-templates.ts @@ -1,506 +1,295 @@ // Auto-generated file - do not edit manually export const TWIG_TEMPLATES: Record = { - 'Shop/shoppingCart': `
            - + 'Shop/shoppingCart': `{# + The shop's cart, which shop.js loads from the cart section (?shopping_cart=1) into its dialog. The site has no page + of its own for the cart section, the editor shows it to edit the cart's texts. + + \`cart-*\` classes style the cart, \`js-cart-*\` classes are shop.js's hooks and carry no styles. In the editor the + same markup shows every part of the cart, with its texts editable in place. +#} +{% macro text(text, caption, isEditMode, tag) %} + {%- set tag = tag ?: 'span' -%} + <{{ tag }}{{ text.attributes|raw }}> + {%- if text.content %}{{ text.content }}{% elseif isEditMode %} {{ caption }} {% endif -%} + +{%- endmacro %} +{% import _self as cart %} +{# A shop with PayPal's REST credentials gets PayPal's buttons, which start with these #} +
            +

            {{ cartTitle }}

            + +
            + {{ cart.text(emptyCart, 'Shopping cart empty', isEditMode, 'div') }}
            -
            {{ cartTitle }}
            +
            + {{ cart.text(expiredCart, 'Shopping cart is expired', isEditMode, 'div') }} +
            - + -
            -
            -
            - {% if isEditMode %} - - {% if tableHeadTitle.content %}{{ tableHeadTitle.content }}{% else %} title {% endif %} -
            - {% else %} - {{ tableHeadTitle.content }} - {% endif %} -
            +
            +
            +
            {{ cart.text(tableHeadTitle, 'Title', isEditMode) }}
            +
            {{ cart.text(tableHeadQuantity, 'Quantity', isEditMode) }}
            +
            {{ cart.text(tableHeadPrice, 'Price', isEditMode) }}
            +
            {{ cart.text(tableHeadSum, 'Sum', isEditMode) }} ({{ currency }})
            +
            -
            -
            -
            - {% if isEditMode %} - - {% if tableHeadQuantity.content %}{{ tableHeadQuantity.content }}{% else %} quantity {% endif %} -
            - {% else %} - {{ tableHeadQuantity.content }} - {% endif %} -
            -
            - {% if isEditMode %} - - {% if tableHeadPrice.content %}{{ tableHeadPrice.content }}{% else %} price {% endif %} -
            - {% else %} - {{ tableHeadPrice.content }} - {% endif %} -
            -
            +
              + + {# Each product in the cart gets a copy of this line #} + + +
              + {% if discountAvailable %} +
              + +
              + {% if isEditMode %} - - {% if tableHeadSum.content %}{{ tableHeadSum.content }}{% else %} sum {% endif %} -
              +
              {{ cart.text(promoButton, 'OK', isEditMode) }}
              {% else %} - {{ tableHeadSum.content }} + {% endif %} - ({{ currency }})
              -
              -
              + {% endif %} -
              -
              -
              -
              -
              -
              + -
              -
              -
              -
              -
              +
              + +
              -
              -
              - {% if discountAvailable %} -
              - {% if isEditMode %} - - {% if promoCode.content %}{{ promoCode.content }}{% else %} Promo code {% endif %} -
              - {% else %} - {{ promoCode.content }} - {% endif %} - - - {% if isEditMode %} - - {% if promoButton.content %}{{ promoButton.content }}{% else %} OK {% endif %} -
              - {% else %} - - {% endif %} - -
              - {% endif %} +
              +
              +
              {{ cart.text(shippingTotal, 'Shipping', isEditMode) }}
              +
              {{ currency }}
              +
              +
              +
              {{ cart.text(discount, 'Discount', isEditMode) }}
              +
              +
              +
              +
              {{ cart.text(total, 'Total', isEditMode) }}
              +
              {{ currency }}
              +
              +
              +
              {{ cart.text(includedVat, 'Incl vat', isEditMode) }} (%)
              +
              {{ currency }}
              +
              +
              -
              -
              - - {{ shippingTo.content }} -
              +
              +
              +

              {{ cart.text(billingAddress, 'Billing address', isEditMode) }}

              - + + +
              +
              + +
              -
              -
              -
              -
              -
              - - - {{ shippingTotal.content }} - -
              {{ currency }}
              +
              + +
              -
              -
              -
              -
              -
              - - - {% if isEditMode %} - - {% if discount.content %}{{ discount.content }}{% else %} discount {% endif %} -
              - {% else %} - {{ discount.content }} - {% endif %} - +
              + +
              -
              -
              -
              -
              -
              - {% if isEditMode %} - - {% if total.content %}{{ total.content }}{% else %} total {% endif %} -
              - {% else %} - {{ total.content }} - {% endif %} +
              + + +
              - {% if isEditMode %} - - {% if includedVat.content %}{{ includedVat.content }}{% else %} incl vat {% endif %} -
              - {% endif %} - -
              {{ currency }}
              +
              + +
              -
              -
              -
              -
              -
              -
              - -
              - - {{ billingAddress.content }} - - -
              -

              - {% if isEditMode %} - - {% if legalPerson.content %}{{ legalPerson.content }}{% else %} legal person {% endif %} -

              - {% else %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ company.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ companyRegistrationNumber.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ legalAddress.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ nameSurname.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ address.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ phone.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ email.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ comments.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              +
              + + +
              - {% if isEditMode %} - - {% if deliverToBillingAddress.content %}{{ deliverToBillingAddress.content }}{% else %} deliver to billing_address {% endif %} -
              - {% else %} -

              - -

              - {% endif %} - +
              + +
              -
              - - {% if shippingAddressHeader.content %}{{ shippingAddressHeader.content }}{% else %} shipping address {% endif %} - - -
              -

              -

              - {% else %} - {{ nameSurname.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              -

              - {% else %} - {{ address.content }} - {% endif %} - - {% if not isEditMode %} - - {% endif %} -

              - -

              - - {% if not isEditMode %} - - {% endif %} -

              - -

              - - {% if not isEditMode %} - - {% endif %} -

              - -

              - - {% if not isEditMode %} - - {% endif %} -

              - +
              + +
              - {% if isEditMode %} -
              - - {% if receiveNews.content %}{{ receiveNews.content }}{% else %} receive news and updates {% endif %} -
              - - {% elseif receiveNews.content %} -
              -   -
              - {% endif %} +
              + + +
              - -
              -
              - - {{ paymentHeader.content }} - + + -
              +
              +

              {{ cart.text(shippingAddressHeader, 'Shipping address', isEditMode) }}

              - {% if isEditMode or paymentDescription.content %} - - {% if paymentDescription.content %}{{ paymentDescription.content }}{% else %} payment description {% endif %} -

              - {% endif %} +
              +
              + + +
              - +
              + + +
              - {% if isPaypalAvailable %} -
              - -
              -
              - -
              - {% endif %} +
              + + +
              - {% if isManualTransferAvailable %} -
              -
              - {% else %} - {{ manualTransfer.content }} - {% endif %} - -
              - {% endif %} +
              + +
              -
              - {% if isEditMode or checkoutDescription.content %} - - {% if checkoutDescription.content %}{{ checkoutDescription.content }}{% else %} checkout description {% endif %} -

              - {% endif %} +
              + + +
              +
              +
              -

              {{ totalLabel }} {{ currency }}

              + {% if isEditMode or receiveNews.content %} + + {% endif %} - {% if isEditMode %} - {% if config.termsLink %} - - {% if terms.content %}{{ terms.content }}{% else %} By purchasing our products you agree to the Terms of Service {% endif %} -
              - {% endif %} +
              +

              {{ cart.text(paymentHeader, 'Choose payment method', isEditMode) }}

              - - {% if fillRequiredFields.content %}{{ fillRequiredFields.content }}{% else %} Please fill in all required fields {% endif %} -
              + {% if isEditMode or paymentDescription.content %} +

              {{ cart.text(paymentDescription, 'Payment description', isEditMode) }}

              + {% endif %} - - {% if checkoutButton.content %}{{ checkoutButton.content }}{% else %} checkout and pay {% endif %} -
              +
              + {% if isPaypalAvailable %} + - - {% if returnToStore.content %}{{ returnToStore.content }}{% else %} return to store {% endif %} -
              + + {% endif %} - {% else %} - {% if config.termsLink %} -

              {{ terms.content }}

              - {% endif %} + {% if isManualTransferAvailable %} + + {% endif %} + + -

              {{ fillRequiredFields.content }}

              +
              + {% if isEditMode or checkoutDescription.content %} +

              {{ cart.text(checkoutDescription, 'Checkout description', isEditMode) }}

              + {% endif %} -

              {{ checkoutButton.content }}

              +
              + {{ totalLabel }} + {{ currency }} +
              -

              {{ returnToStore.content }}

              + {% if config.termsLink %} +

              + {% if isEditMode %} + {{ cart.text(terms, 'By purchasing our products you agree to the Terms of Service', isEditMode) }} + {% else %} + {{ terms.content }} {% endif %} +

              + {% endif %} - -
              + + + {# How a PayPal payment went when it didn't go through #} + + +
              + {% if isEditMode %} +
              {{ cart.text(checkoutButton, 'Checkout and pay', isEditMode) }}
              + {% else %} + + {% endif %}
              + +

              + {% if isEditMode %} + {{ cart.text(returnToStore, 'Return to store', isEditMode) }} + {% else %} + + {% endif %} +

              @@ -968,8 +757,8 @@ export const TWIG_TEMPLATES: Record = { {% endif %} - {{ addToBasketLabel }} - + + `, @@ -1215,9 +1004,6 @@ export const TWIG_TEMPLATES: Record = { {{ sectionFooter|raw }} {% endif %} - {% if alertMessage %} - - {% endif %} `, diff --git a/editor/src/app/rerender/template-rerender.service.ts b/editor/src/app/rerender/template-rerender.service.ts index fb4e17b21..53c2da732 100644 --- a/editor/src/app/rerender/template-rerender.service.ts +++ b/editor/src/app/rerender/template-rerender.service.ts @@ -138,6 +138,7 @@ export class TemplateRerenderService { [ 'currency', 'addToBasket', + 'promoCode', 'promoCodeDiscount', 'termsLink', ].includes(action.payload.field)) || diff --git a/editor/src/app/shared/delayed-indicator.spec.ts b/editor/src/app/shared/delayed-indicator.spec.ts new file mode 100644 index 000000000..3cc97e41d --- /dev/null +++ b/editor/src/app/shared/delayed-indicator.spec.ts @@ -0,0 +1,71 @@ +import { fakeAsync, tick } from '@angular/core/testing'; +import { Subject, Subscription } from 'rxjs'; + +import { delayedIndicator } from './delayed-indicator'; + +describe('delayedIndicator', () => { + let isLoading: Subject; + let shows: boolean[]; + let subscription: Subscription; + + beforeEach(() => { + isLoading = new Subject(); + shows = []; + subscription = isLoading + .pipe(delayedIndicator()) + .subscribe((value) => shows.push(value)); + }); + + afterEach(() => subscription.unsubscribe()); + + it('shows nothing during a quick load', fakeAsync(() => { + isLoading.next(false); + isLoading.next(true); + tick(299); + isLoading.next(false); + tick(1000); + + expect(shows).toEqual([false]); + })); + + it('shows a slow load after the delay, and for at least the minimum time', fakeAsync(() => { + isLoading.next(true); + tick(299); + expect(shows).toEqual([]); + + tick(1); + expect(shows).toEqual([true]); + + tick(100); + isLoading.next(false); + tick(399); + expect(shows).toEqual([true]); + + tick(1); + expect(shows).toEqual([true, false]); + })); + + it('hides at once after a load longer than the minimum time', fakeAsync(() => { + isLoading.next(true); + tick(900); + isLoading.next(false); + tick(0); + + expect(shows).toEqual([true, false]); + })); + + it('keeps showing through a load that starts as the last one ends', fakeAsync(() => { + isLoading.next(true); + tick(400); + isLoading.next(false); + tick(100); + isLoading.next(true); + tick(1000); + + expect(shows).toEqual([true]); + + isLoading.next(false); + tick(0); + expect(shows).toEqual([true, false]); + })); +}); diff --git a/editor/src/app/shared/delayed-indicator.ts b/editor/src/app/shared/delayed-indicator.ts new file mode 100644 index 000000000..55422ebe5 --- /dev/null +++ b/editor/src/app/shared/delayed-indicator.ts @@ -0,0 +1,47 @@ +import { MonoTypeOperatorFunction, defer, of, timer } from 'rxjs'; +import { distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; + +/** A load quicker than this shows no loading indicator */ +export const LOADING_INDICATOR_DELAY = 300; + +/** A shown loading indicator stays at least this long, so it doesn't blink */ +export const LOADING_INDICATOR_MIN_DURATION = 500; + +/** + * Turns whether something loads into whether its indicator shows. A quick load shows nothing, and + * a shown indicator stays at least LOADING_INDICATOR_MIN_DURATION, through a load that starts + * again meanwhile. + */ +export function delayedIndicator(): MonoTypeOperatorFunction { + return (isLoading$) => + defer(() => { + let shownAt: number | null = null; + + return isLoading$.pipe( + distinctUntilChanged(), + switchMap((isLoading) => { + if (shownAt !== null) { + if (isLoading) { + return of(true); + } + const shownFor = Date.now() - shownAt; + + return timer( + Math.max(0, LOADING_INDICATOR_MIN_DURATION - shownFor), + ).pipe( + tap(() => (shownAt = null)), + map(() => false), + ); + } + + return isLoading + ? timer(LOADING_INDICATOR_DELAY).pipe( + tap(() => (shownAt = Date.now())), + map(() => true), + ) + : of(false); + }), + distinctUntilChanged(), + ); + }); +} diff --git a/editor/src/app/shared/pending-requests.spec.ts b/editor/src/app/shared/pending-requests.spec.ts new file mode 100644 index 000000000..fa37cc032 --- /dev/null +++ b/editor/src/app/shared/pending-requests.spec.ts @@ -0,0 +1,109 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClient, + provideHttpClient, + withInterceptors, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { BehaviorSubject, Subject, Subscription } from 'rxjs'; + +import { + PendingRequestsService, + pendingRequestsInterceptor, + whileRequestsPending, +} from './pending-requests'; + +describe('pendingRequestsInterceptor', () => { + let http: HttpClient; + let backend: HttpTestingController; + let counts: number[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([pendingRequestsInterceptor])), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + backend = TestBed.inject(HttpTestingController); + counts = []; + TestBed.inject(PendingRequestsService).count$.subscribe((count) => + counts.push(count), + ); + }); + + afterEach(() => backend.verify()); + + it('counts a request until it is answered or fails', () => { + http.get('/_api/v1/meta').subscribe(); + http.get('/_api/v1/state').subscribe({ error: () => {} }); + + backend.expectOne('/_api/v1/meta').flush({}); + backend + .expectOne('/_api/v1/state') + .flush('', { status: 500, statusText: 'Server Error' }); + + expect(counts).toEqual([0, 1, 2, 1, 0]); + }); + + it('stops counting a canceled request', () => { + const request = http.get('/_api/v1/meta').subscribe(); + const pending = backend.expectOne('/_api/v1/meta'); + + request.unsubscribe(); + + expect(pending.cancelled).toBeTrue(); + expect(counts).toEqual([0, 1, 0]); + }); +}); + +describe('whileRequestsPending', () => { + let isLoading: Subject; + let count: BehaviorSubject; + let isOn: boolean[]; + let subscription: Subscription; + + beforeEach(() => { + isLoading = new Subject(); + count = new BehaviorSubject(0); + isOn = []; + subscription = isLoading + .pipe(whileRequestsPending(count)) + .subscribe((value) => isOn.push(value)); + }); + + afterEach(() => subscription.unsubscribe()); + + it('stays on while a request is still pending, until the last one ends', () => { + isLoading.next(true); + count.next(1); + count.next(2); + // The first request ends and turns the flag off + count.next(1); + isLoading.next(false); + + expect(isOn).toEqual([true]); + + count.next(0); + expect(isOn).toEqual([true, false]); + }); + + it('stays off for requests while the flag is off', () => { + isLoading.next(false); + count.next(1); + count.next(0); + + expect(isOn).toEqual([false]); + }); + + it('turns off with the flag when no request is pending', () => { + isLoading.next(true); + isLoading.next(false); + + expect(isOn).toEqual([true, false]); + }); +}); diff --git a/editor/src/app/shared/pending-requests.ts b/editor/src/app/shared/pending-requests.ts new file mode 100644 index 000000000..aea60f877 --- /dev/null +++ b/editor/src/app/shared/pending-requests.ts @@ -0,0 +1,50 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + BehaviorSubject, + MonoTypeOperatorFunction, + Observable, + combineLatest, + defer, +} from 'rxjs'; +import { distinctUntilChanged, finalize, scan } from 'rxjs/operators'; + +/** + * The HTTP requests in flight. A request counts until it ends, however it ends: answered, failed + * or canceled. + */ +@Injectable({ providedIn: 'root' }) +export class PendingRequestsService { + private readonly count = new BehaviorSubject(0); + readonly count$ = this.count.asObservable(); + + track(request$: Observable): Observable { + return defer(() => { + this.count.next(this.count.value + 1); + + return request$.pipe( + finalize(() => this.count.next(this.count.value - 1)), + ); + }); + } +} + +export const pendingRequestsInterceptor: HttpInterceptorFn = (request, next) => + inject(PendingRequestsService).track(next(request)); + +/** + * Keeps a loading flag on while requests are pending, so the first of several requests to end + * doesn't turn it off. Requests alone don't turn it on. + */ +export function whileRequestsPending( + count$: Observable, +): MonoTypeOperatorFunction { + return (isLoading$) => + combineLatest([isLoading$, count$]).pipe( + scan( + (isOn, [isLoading, count]) => isLoading || (isOn && count > 0), + false, + ), + distinctUntilChanged(), + ); +} diff --git a/editor/src/app/shared/refresh-indicator.ts b/editor/src/app/shared/refresh-indicator.ts new file mode 100644 index 000000000..796200f44 --- /dev/null +++ b/editor/src/app/shared/refresh-indicator.ts @@ -0,0 +1,82 @@ +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +import { LOADING_INDICATOR_DELAY } from './delayed-indicator'; + +/** + * The refresh icon's half turn, as in its spin in _utils.scss. Its arrows look the same after a + * half turn, so the spin stops on one without a jump. + */ +const REFRESH_ICON_HALF_TURN = 500; + +/** + * The loading state behind a list's Refresh button. A quick load shows nothing, so the indicator + * doesn't flash. + */ +export class RefreshIndicator { + isLoading = false; + /** + * Whether the refresh icon spins: from LOADING_INDICATOR_DELAY into a load until the half turn + * it's on when the load ends + */ + showsLoading = false; + private loadCount = 0; + private shownAt = 0; + /** Shows the indicator while loading, hides it after the load */ + private timer: ReturnType | undefined; + + /** + * Indicates the load while it runs. A load started while another runs takes the indicator over, + * so the other's end doesn't stop it. + * + * @param onEnd Runs when the load ends, unless a newer one has started + */ + track(load$: Observable, onEnd?: () => void): Observable { + const load = ++this.loadCount; + this.start(); + + return load$.pipe( + finalize(() => { + if (load === this.loadCount) { + onEnd?.(); + this.stop(); + } + }), + ); + } + + destroy() { + clearTimeout(this.timer); + } + + private start() { + if (!this.isLoading) { + // Keeps spinning an icon that is about to stop + clearTimeout(this.timer); + if (!this.showsLoading) { + this.timer = setTimeout(() => { + this.showsLoading = true; + this.shownAt = Date.now(); + }, LOADING_INDICATOR_DELAY); + } + } + this.isLoading = true; + } + + /** Stops the icon at the end of its half turn, where it looks as at rest */ + private stop() { + this.isLoading = false; + clearTimeout(this.timer); + if (this.showsLoading) { + const shownFor = Date.now() - this.shownAt; + const halfTurns = Math.max( + 1, + Math.ceil(shownFor / REFRESH_ICON_HALF_TURN), + ); + this.timer = setTimeout( + () => (this.showsLoading = false), + halfTurns * REFRESH_ICON_HALF_TURN - shownFor, + ); + } + } +} diff --git a/editor/src/app/shop/orders/shop-orders.actions.ts b/editor/src/app/shop/orders/shop-orders.actions.ts index 15f6bf0b7..343b59627 100644 --- a/editor/src/app/shop/orders/shop-orders.actions.ts +++ b/editor/src/app/shop/orders/shop-orders.actions.ts @@ -11,10 +11,28 @@ export class DeleteShopOrdersSiteAction { constructor(public payload: string) {} } -/* Init: */ -export class InitShopOrdersAction { - public static type = 'SHOP_ORDERS:INIT'; - constructor(public payload: any) {} +export class LoadShopOrdersPageAction { + static readonly type = 'SHOP_ORDERS:LOAD_PAGE'; + 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, + ) {} +} + +export class UpdateShopOrderNotesAction { + static readonly type = 'SHOP_ORDERS:UPDATE_NOTES'; + constructor( + public id: number, + public notes: string, + ) {} } /* 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..eb5366539 --- /dev/null +++ b/editor/src/app/shop/orders/shop-orders.component.spec.ts @@ -0,0 +1,354 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; +import { Actions, ActionStatus, Store } from '@ngxs/store'; +import { BehaviorSubject, Subject, of } from 'rxjs'; + +import { AppState } from '../../app-state/app.state'; +import { SitesSharedModule } from '../../sites/shared/sites-shared.module'; +import { SwapContentsSitesAction } from '../../sites/sites-state/sites.actions'; +import { ShopSettingsState } from '../settings/shop-settings.state'; +import { ShopStateService } from '../shop-state.service'; +import { + LoadShopOrdersPageAction, + UpdateShopOrderNotesAction, +} from './shop-orders.actions'; +import { ShopOrdersComponent } from './shop-orders.component'; +import { ShopOrdersPage, ShopOrdersState } from './shop-orders.state'; + +describe('ShopOrdersComponent', () => { + const ORDERS_PAGE: ShopOrdersPage = { + orders: [ + { + id: 6, + order_id: 'ORD-6', + admin_notes: 'Ships on Friday.', + paypal: 1, + payment_method: 'creditcard', + orders: [], + }, + // Placed before the shop saved the payment method + { id: 5, order_id: 'ORD-5', admin_notes: null, paypal: 1, orders: [] }, + ], + page: 1, + lastPage: 1, + total: 2, + statusFilter: null, + }; + + let fixture: ComponentFixture; + let dispatch: jasmine.Spy; + let getOrderInvoice: jasmine.Spy; + let invoicesEnabled: boolean; + /** The status the stored orders are filtered by */ + let statusFilter: string | null; + let site$: BehaviorSubject; + let actions$: Subject<{ action: unknown; status: ActionStatus }>; + + 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'); + const invoiceButton = (card: HTMLElement) => + card.querySelector('.bt-sh-order-download-invoice'); + const paymentMethod = (card: HTMLElement) => + Array.from(card.querySelectorAll('p')) + .find((row) => row.textContent.trim().startsWith('Payment')) + .querySelector('span') + .textContent.trim(); + + /** 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); + }; + + const createComponent = () => { + fixture?.nativeElement.remove(); + fixture = TestBed.createComponent(ShopOrdersComponent); + document.body.appendChild(fixture.nativeElement); + fixture.detectChanges(); + }; + + beforeEach(async () => { + dispatch = jasmine.createSpy('dispatch').and.returnValue(of(undefined)); + getOrderInvoice = jasmine.createSpy('getOrderInvoice'); + invoicesEnabled = true; + statusFilter = null; + site$ = new BehaviorSubject('second'); + actions$ = new Subject(); + const store = { + select: (selector: unknown) => { + if (selector === AppState.getSite) { + return site$; + } + if (selector === ShopSettingsState.getCurrentCurrency) { + return of('EUR'); + } + if (selector === ShopSettingsState.getCurrentInvoicesEnabled) { + return of(invoicesEnabled); + } + return of(ORDERS_PAGE); + }, + selectSnapshot: (selector: unknown) => { + if (selector === AppState.getSite) { + return 'second'; + } + if (selector === ShopOrdersState.getCurrentSiteOrdersPage) { + return { ...ORDERS_PAGE, statusFilter }; + } + return { orders: 'http://berta.test/_api/v1/plugin/shop/orders' }; + }, + dispatch, + } as unknown as Store; + + await TestBed.configureTestingModule({ + imports: [SitesSharedModule], + declarations: [ShopOrdersComponent], + providers: [ + { provide: Store, useValue: store }, + { provide: Actions, useValue: actions$ }, + { provide: ShopStateService, useValue: { getOrderInvoice } }, + ], + }).compileComponents(); + + createComponent(); + }); + + afterEach(() => { + fixture.nativeElement.remove(); + }); + + it('shows how an order was paid, and PayPal for an older order paid through PayPal', () => { + const [cardOrder, olderOrder] = orderCards(); + + expect(paymentMethod(cardOrder)).toBe('Debit or credit card'); + expect(paymentMethod(olderOrder)).toBe('PayPal'); + }); + + 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.toHaveBeenCalledWith( + jasmine.any(UpdateShopOrderNotesAction), + ); + }); + + 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); + }); + + it('offers the invoice of each order only while the shop makes invoices', () => { + expect(orderCards().map((card) => !!invoiceButton(card))).toEqual([ + true, + true, + ]); + + invoicesEnabled = false; + createComponent(); + + expect(orderCards().map((card) => !!invoiceButton(card))).toEqual([ + false, + false, + ]); + }); + + describe('opening', () => { + let scrollIntoView: jasmine.Spy; + + beforeEach(() => { + dispatch.calls.reset(); + scrollIntoView = spyOn(HTMLElement.prototype, 'scrollIntoView'); + }); + + it('loads the newest orders of the chosen status as the shop opens, without scrolling to them', () => { + statusFilter = 'paid'; + createComponent(); + + expect(dispatch.calls.allArgs()).toEqual([ + [new LoadShopOrdersPageAction(1, 'paid')], + ]); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('loads the newest orders of the site switched to, and of the site whose contents were swapped in', () => { + site$.next('third'); + actions$.next({ + action: new SwapContentsSitesAction({ + siteSlugFrom: 'second', + siteSlugTo: 'third', + }), + status: ActionStatus.Successful, + }); + + expect(dispatch.calls.allArgs()).toEqual([ + [new LoadShopOrdersPageAction(1, null)], + [new LoadShopOrdersPageAction(1, null)], + ]); + }); + }); + + describe('loading', () => { + let load: Subject; + + const refreshButton = (): HTMLButtonElement => + fixture.nativeElement.querySelector('.bt-sh-orders-actions button'); + const chip = (title: string) => + Array.from( + fixture.nativeElement.querySelectorAll('.bt-sh-orders-filter button'), + ).find((button) => button.textContent.trim() === title); + const spins = () => refreshButton().classList.contains('bt-loading'); + const fades = () => + fixture.nativeElement.classList.contains('bt-sh-orders-loading'); + + /** Lets time pass, then renders */ + const wait = (ms: number) => { + tick(ms); + fixture.detectChanges(); + }; + const finishLoad = () => { + load.next(); + load.complete(); + fixture.detectChanges(); + }; + + beforeEach(() => { + load = new Subject(); + dispatch.and.returnValue(load); + spyOn(HTMLElement.prototype, 'scrollIntoView'); + }); + + it('chooses the clicked status chip before its orders have loaded', fakeAsync(() => { + chip('Paid').click(); + fixture.detectChanges(); + + expect(dispatch).toHaveBeenCalledWith( + new LoadShopOrdersPageAction(1, 'paid'), + ); + expect(chip('Paid').getAttribute('aria-pressed')).toBe('true'); + expect(chip('All').getAttribute('aria-pressed')).toBe('false'); + + load.error(new Error('Offline')); + fixture.detectChanges(); + + expect(chip('All').getAttribute('aria-pressed')).toBe('true'); + })); + + it('shows nothing during a quick load', fakeAsync(() => { + refreshButton().click(); + wait(250); + finishLoad(); + wait(1000); + + expect(spins()).toBeFalse(); + expect(fades()).toBeFalse(); + expect(refreshButton().textContent.trim()).toBe('Refresh'); + })); + + it('spins the icon and fades the orders during a slow load, then stops the icon on a half turn', fakeAsync(() => { + refreshButton().click(); + wait(299); + + expect(spins()).toBeFalse(); + expect(fades()).toBeFalse(); + expect(fixture.nativeElement.getAttribute('aria-busy')).toBe('true'); + + wait(1); + expect(spins()).toBeTrue(); + expect(fades()).toBeTrue(); + expect(refreshButton().disabled).toBeFalse(); + + wait(600); + finishLoad(); + + expect(fades()).toBeFalse(); + expect(spins()).toBeTrue(); + expect(fixture.nativeElement.getAttribute('aria-busy')).toBe('false'); + + wait(399); + expect(spins()).toBeTrue(); + + wait(1); + expect(spins()).toBeFalse(); + })); + + it('keeps the icon spinning through a load started as the last one ends', fakeAsync(() => { + refreshButton().click(); + wait(400); + finishLoad(); + wait(100); + + load = new Subject(); + dispatch.and.returnValue(load); + chip('Paid').click(); + wait(1000); + + expect(spins()).toBeTrue(); + expect(fades()).toBeTrue(); + })); + }); + + it("saves an order's invoice as a PDF named by the order's number", () => { + getOrderInvoice.and.returnValue( + of(new Blob(['%PDF-'], { type: 'application/pdf' })), + ); + const click = spyOn(HTMLAnchorElement.prototype, 'click'); + + invoiceButton(orderCards()[0]).click(); + + expect(getOrderInvoice).toHaveBeenCalledWith( + 'http://berta.test/_api/v1/plugin/shop/orders', + 'second', + 6, + ); + expect(click).toHaveBeenCalledTimes(1); + const link = click.calls.mostRecent().object as HTMLAnchorElement; + expect(link.download).toBe('invoice-ORD-6.pdf'); + expect(link.href).toMatch(/^blob:/); + }); +}); diff --git a/editor/src/app/shop/orders/shop-orders.component.ts b/editor/src/app/shop/orders/shop-orders.component.ts index 0d6680205..062b7b40a 100644 --- a/editor/src/app/shop/orders/shop-orders.component.ts +++ b/editor/src/app/shop/orders/shop-orders.component.ts @@ -1,140 +1,437 @@ -import { Component } from '@angular/core'; -import { Store } from '@ngxs/store'; -import { Observable } from 'rxjs'; +import { + Component, + DestroyRef, + ElementRef, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Actions, Store, ofActionSuccessful } from '@ngxs/store'; +import { Observable, merge } from 'rxjs'; +import { filter, finalize } from 'rxjs/operators'; +import { AppState } from '../../app-state/app.state'; import { stringToCurrency } from '../../shared/helpers'; +import { SwapContentsSitesAction } from '../../sites/sites-state/sites.actions'; +import { RefreshIndicator } from '../../shared/refresh-indicator'; import { ShopSettingsState } from '../settings/shop-settings.state'; -import { ShopOrdersState } from './shop-orders.state'; +import { ShopStateService } from '../shop-state.service'; +import { ShopState } from '../shop.state'; +import { + LoadShopOrdersPageAction, + UpdateShopOrderNotesAction, + 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' }, +]; + +/** The payment methods of ShopCheckoutService in the shop plugin */ +const PAYMENT_METHODS: Record = { + bank_transfer: 'Bank transfer', + paypal: 'PayPal', + creditcard: 'Debit or credit card', +}; @Component({ selector: 'berta-shop-orders', template: ` - @for (order of orders$ | async; track order) { -
              -

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

              + @if (ordersPage$ | async; as ordersPage) { +

              - Created {{ order.created_at }} + {{ ordersPage.total }} + {{ ordersPage.total === 1 ? 'order' : 'orders' }}

              - @if (order.company) { +
              + +
              +
              + @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 }} +

              + +

              - Company {{ order.company }} + Created {{ order.created_at }}

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

              + Company {{ order.company }} +

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

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

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

              + VAT no. {{ order.vat_no }} +

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

              + Legal address {{ order.legal_address }} +

              + }

              - Company reg. no. {{ order.company_reg_no }} + Name {{ order.name }}

              - } - @if (order.legal_address) {

              - Legal address {{ order.legal_address }} + Phone {{ order.phone }}

              - } -

              - 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 }}
              - } -
              + Address {{ order.address }}

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

              Send news Yes

              - } - @if (order.promo_code) {

              - Promo code {{ order.promo_code }} + Email {{ order.email }}

              - } -

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

              -

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

              -

              - Total - - + Customer notes + {{ order.notes }} +

              + } + @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) }} + +

              +

              + Payment + {{ paymentMethodTitle(order) }} +

              + @if (order.payment_reference) { +

              + PayPal transaction {{ order.payment_reference }} +

              + } +
              + @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 (invoicesEnabled$ | async) { + + } + @if (order.admin_notes || notesOpenedFor.has(order.id)) { + + } @else { + }
              -
              + } + @if (ordersPage.lastPage > 1) { +
              + +

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

              + +
              + } } `, + host: { + // The listed orders fade only while a slow load is still running + '[class.bt-sh-orders-loading]': 'loading.isLoading && loading.showsLoading', + '[attr.aria-busy]': 'loading.isLoading', + }, standalone: false, }) -export class ShopOrdersComponent { - orders$: Observable; +export class ShopOrdersComponent implements OnInit, OnDestroy { + ordersPage$: Observable; currency$: Observable; + invoicesEnabled$: Observable; + readonly loading = new RefreshIndicator(); + private destroyRef = inject(DestroyRef); + /** The status chosen while its orders load, undefined while none are loading */ + pendingStatusFilter: string | null | undefined; + /** The orders without notes whose notes field the shop owner has opened */ + notesOpenedFor = new Set(); + /** The orders whose invoice is being downloaded */ + invoiceLoadingFor = new Set(); - constructor(private store: Store) { - this.orders$ = this.store.select(ShopOrdersState.getCurrentSiteOrders); + // '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 actions$: Actions, + private elementRef: ElementRef, + private shopStateService: ShopStateService, + ) { + this.ordersPage$ = this.store.select( + ShopOrdersState.getCurrentSiteOrdersPage, + ); this.currency$ = this.store.select(ShopSettingsState.getCurrentCurrency); + this.invoicesEnabled$ = this.store.select( + ShopSettingsState.getCurrentInvoicesEnabled, + ); + } + + ngOnInit() { + // Shoppers place orders while the editor is open: the newest orders of the chosen status load + // as the shop opens, for each site it shows, and for the site whose contents were swapped in + merge( + this.store.select(AppState.getSite).pipe(filter((site) => site !== null)), + this.actions$.pipe(ofActionSuccessful(SwapContentsSitesAction)), + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + const ordersPage = this.store.selectSnapshot( + ShopOrdersState.getCurrentSiteOrdersPage, + ); + + this.loadPage(1, ordersPage?.statusFilter ?? null).subscribe({ + // The service shows the error + error: () => {}, + }); + }); + } + + ngOnDestroy() { + this.loading.destroy(); + } + + /** The chosen status chip: the one clicked, before its orders have loaded */ + activeStatusFilter(ordersPage: ShopOrdersPage): string | null { + return this.pendingStatusFilter !== undefined + ? this.pendingStatusFilter + : (ordersPage.statusFilter ?? null); + } + + /** Shows a page of orders from its top */ + goToPage(page: number, statusFilter: string | null) { + this.loadPage(page, statusFilter).subscribe({ + next: () => { + this.elementRef.nativeElement.scrollIntoView({ block: 'start' }); + }, + // The service shows the error + error: () => {}, + }); + } + + /** A load started while another runs cancels the other */ + private loadPage(page: number, statusFilter: string | null) { + this.pendingStatusFilter = statusFilter; + + return this.loading.track( + this.store.dispatch(new LoadShopOrdersPageAction(page, statusFilter)), + () => (this.pendingStatusFilter = undefined), + ); + } + + updateStatus(orderId: number, status: string) { + 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); + } + } + + /** Saves the order's invoice PDF. The shop's message is shown when it has none. */ + downloadInvoice(order: { id: number; order_id: string }) { + const site = this.store.selectSnapshot(AppState.getSite); + const ordersUrl = this.store.selectSnapshot(ShopState.getURLs).orders; + + this.invoiceLoadingFor.add(order.id); + this.shopStateService + .getOrderInvoice(ordersUrl, site, order.id) + .pipe(finalize(() => this.invoiceLoadingFor.delete(order.id))) + .subscribe({ + next: (pdf) => { + const url = URL.createObjectURL(pdf); + const link = document.createElement('a'); + link.href = url; + link.download = `invoice-${order.order_id}.pdf`; + link.click(); + // Once the browser has taken the file + setTimeout(() => URL.revokeObjectURL(url)); + }, + // The service shows the error + error: () => {}, + }); + } + + /** + * Orders placed before the shop saved the payment method only know whether they went through + * PayPal, which took card payments too + */ + paymentMethodTitle(order: { + payment_method?: string | null; + paypal?: number | null; + }) { + return ( + PAYMENT_METHODS[order.payment_method] ?? + (order.paypal ? PAYMENT_METHODS.paypal : PAYMENT_METHODS.bank_transfer) + ); } 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..6768ca08b --- /dev/null +++ b/editor/src/app/shop/orders/shop-orders.state.spec.ts @@ -0,0 +1,159 @@ +import { StateContext, Store } from '@ngxs/store'; +import { BehaviorSubject, of } from 'rxjs'; + +import { + ShopOrdersModel, + ShopOrdersPage, + ShopOrdersState, +} from './shop-orders.state'; +import { + LoadShopOrdersPageAction, + UpdateShopOrderNotesAction, + UpdateShopOrderStatusAction, +} from './shop-orders.actions'; +import { AppState } from '../../app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; +import { ShopState } from '../shop.state'; + +describe('ShopOrdersState', () => { + const SECOND_PAGE: ShopOrdersPage = { + orders: [{ id: 5, order_id: 'ORD-5', status: null, orders: [] }], + page: 2, + lastPage: 2, + total: 21, + statusFilter: null, + }; + + let urls$: BehaviorSubject<{ [sectionSlug: string]: string }>; + let sync: jasmine.Spy; + let patchState: jasmine.Spy; + let state: ShopOrdersState; + + beforeEach(() => { + urls$ = new BehaviorSubject({ orders: '/orders' }); + sync = jasmine + .createSpy('sync') + .and.returnValue(of({ message: '', data: { blog: SECOND_PAGE } })); + patchState = jasmine.createSpy('patchState'); + const store = { + select: (selector: unknown) => + selector === ShopState.getURLs ? urls$ : undefined, + selectSnapshot: (selector: unknown) => + selector === AppState.getSite ? 'blog' : { orders: '/orders' }, + } as unknown as Store; + + state = new ShopOrdersState(store, { sync } as unknown as AppStateService); + }); + + 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 }); + }); + + it("loads the orders once the shop's URLs have come", () => { + const context = { patchState } as unknown as StateContext; + urls$.next({}); + + state.loadOrdersPage(context, new LoadShopOrdersPageAction(1)).subscribe(); + + expect(sync).not.toHaveBeenCalled(); + + urls$.next({ orders: '/orders' }); + + expect(sync).toHaveBeenCalledWith( + '/orders', + { site: 'blog', page: 1 }, + 'GET', + ); + 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' }, + ], + }, + }); + }); + + 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 eea9bd809..07f119cc2 100644 --- a/editor/src/app/shop/orders/shop-orders.state.ts +++ b/editor/src/app/shop/orders/shop-orders.state.ts @@ -1,30 +1,30 @@ -import { startWith } from 'rxjs'; -import { take, filter, switchMap, distinct, map } from 'rxjs/operators'; -import { - State, - StateContext, - NgxsOnInit, - Selector, - Action, - Store, - Actions, - ofActionSuccessful, -} from '@ngxs/store'; -import { ShopStateService } from '../shop-state.service'; +import { tap, catchError, filter, take, switchMap } from 'rxjs/operators'; +import { State, StateContext, Selector, Action, Store } from '@ngxs/store'; +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, + UpdateShopOrderStatusAction, + UpdateShopOrderNotesAction, } from './shop-orders.actions'; -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'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +export interface ShopOrdersPage { + orders: any[]; + page: number; + lastPage: number; + total: number; + /** The status the orders are filtered by, null for all orders */ + statusFilter: string | null; +} -interface ShopOrdersModel { - [site: string]: any[]; +export interface ShopOrdersModel { + [site: string]: ShopOrdersPage; } const defaultState: ShopOrdersModel = {}; @@ -34,59 +34,113 @@ const defaultState: ShopOrdersModel = {}; defaults: defaultState, }) @Injectable() -export class ShopOrdersState implements NgxsOnInit { +export class ShopOrdersState { @Selector([ShopOrdersState, AppState.getSite]) - static getCurrentSiteOrders(state: ShopOrdersModel, site: string) { + static getCurrentSiteOrdersPage( + state: ShopOrdersModel, + site: string, + ): ShopOrdersPage { return state[site]; } - private destroyRef = inject(DestroyRef); - private counter = 0; - constructor( private store$: Store, - private stateService: ShopStateService, - private actions$: Actions, + private appStateService: AppStateService, ) {} - ngxsOnInit({ dispatch }: StateContext) { - combineLatest([ - this.store$.select(AppState.getSite), - this.actions$.pipe( - ofActionSuccessful(SwapContentsSitesAction), - startWith(null), // Emit initial value so combineLatest starts immediately - ), - ]) - .pipe( - filter(([site]) => site !== null), - map(([site, action]) => ({ - site, - isSwapSitesContentsAction: action !== null, - })), - distinct(({ site, isSwapSitesContentsAction }) => - isSwapSitesContentsAction ? this.counter++ : site, - ), - switchMap(({ site, isSwapSitesContentsAction }) => - this.stateService - .getInitialState(site, 'orders', isSwapSitesContentsAction) - .pipe( - take(1), - map((orders) => ({ site, orders })), - ), + /** Cancels the load still running, so a slower earlier response can't replace the newer page */ + @Action(LoadShopOrdersPageAction, { cancelUncompleted: true }) + loadOrdersPage( + { patchState }: StateContext, + action: LoadShopOrdersPageAction, + ) { + const site = this.store$.selectSnapshot(AppState.getSite); + + // The shop's URLs come with its state, still loading in an editor just opened on the shop + return this.store$.select(ShopState.getURLs).pipe( + filter((urls) => !!urls.orders), + take(1), + switchMap((urls) => + this.appStateService.sync( + urls.orders, + { + site, + page: action.page, + ...(action.statusFilter ? { status: action.statusFilter } : {}), + }, + 'GET', ), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(({ site, orders }) => { - dispatch(new InitShopOrdersAction({ [site]: orders[site] })); - }); + ), + 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(InitShopOrdersAction) - initShopOrders( - { patchState }: StateContext, - action: InitShopOrdersAction, + /** + * 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( + 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, ) { - patchState(action.payload); + const site = this.store$.selectSnapshot(AppState.getSite); + const syncURLs = this.store$.selectSnapshot(ShopState.getURLs); + + return this.appStateService + .sync(syncURLs.orders, { path: `${site}/${id}/${field}`, value }, 'PATCH') + .pipe( + tap((response: { message: string; data: { value: string | null } }) => { + const ordersPage = getState()[site]; + + patchState({ + [site]: { + ...ordersPage, + orders: ordersPage.orders.map((order) => + order.id === id + ? { ...order, [field]: 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) diff --git a/editor/src/app/shop/products/shop-products.actions.ts b/editor/src/app/shop/products/shop-products.actions.ts index 4e35574df..4af4d8f02 100644 --- a/editor/src/app/shop/products/shop-products.actions.ts +++ b/editor/src/app/shop/products/shop-products.actions.ts @@ -22,10 +22,9 @@ export class DeleteShopProductSiteAction { constructor(public payload: string) {} } -/* Init: */ -export class InitShopProductsAction { - public static type = 'SHOP_PRODUCT:INIT'; - constructor(public payload: any) {} +/** Loads the current site's products with their stock and reservations */ +export class LoadShopProductsAction { + static readonly type = 'SHOP_PRODUCT:LOAD'; } /* Reset: */ diff --git a/editor/src/app/shop/products/shop-products.component.spec.ts b/editor/src/app/shop/products/shop-products.component.spec.ts new file mode 100644 index 000000000..b8af9c7a3 --- /dev/null +++ b/editor/src/app/shop/products/shop-products.component.spec.ts @@ -0,0 +1,135 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Actions, ActionStatus, Store } from '@ngxs/store'; +import { BehaviorSubject, Subject, of } from 'rxjs'; + +import { AppState } from '../../app-state/app.state'; +import { SiteSectionsState } from '../../sites/sections/sections-state/site-sections.state'; +import { SectionTagsState } from '../../sites/sections/tags/section-tags.state'; +import { SectionEntriesState } from '../../sites/sections/entries/entries-state/section-entries.state'; +import { + UpdateSectionEntryAction, + UpdateSectionEntryFromSyncAction, +} from '../../sites/sections/entries/entries-state/section-entries.actions'; +import { SitesSharedModule } from '../../sites/shared/sites-shared.module'; +import { SwapContentsSitesAction } from '../../sites/sites-state/sites.actions'; +import { LoadShopProductsAction } from './shop-products.actions'; +import { ShopProductsComponent } from './shop-products.component'; +import { ShopProduct, ShopProductsState } from './shop-products.state'; + +describe('ShopProductsComponent', () => { + const PRODUCTS: ShopProduct[] = [ + { id: '1', uniqid: 'abc', name: 'Poster', instock: 3, reservation: 1 }, + ]; + + let fixture: ComponentFixture; + let dispatch: jasmine.Spy; + let site$: BehaviorSubject; + let products$: BehaviorSubject; + let actions$: Subject<{ action: unknown; status: ActionStatus }>; + + const refreshButton = (): HTMLButtonElement => + fixture.nativeElement.querySelector('.bt-sh-refresh'); + const loadCount = () => + dispatch.calls + .allArgs() + .filter(([action]) => action instanceof LoadShopProductsAction).length; + const succeed = (action: unknown) => + actions$.next({ action, status: ActionStatus.Successful }); + + beforeEach(async () => { + dispatch = jasmine.createSpy('dispatch').and.returnValue(of(undefined)); + site$ = new BehaviorSubject('blog'); + products$ = new BehaviorSubject(PRODUCTS); + actions$ = new Subject(); + const selected = new Map([ + [AppState.getSite, site$], + [ + SiteSectionsState.getCurrentSiteShopSections, + of([{ name: 'shop', title: 'Shop' }]), + ], + [SectionTagsState.getCurrentSiteTags, of([])], + [ + SectionEntriesState.getCurrentSiteEntries, + of([ + { + uniqid: 'abc', + sectionName: 'shop', + content: { cartTitle: 'Poster' }, + }, + ]), + ], + [ShopProductsState.getCurrentSiteProducts, products$], + ]); + const store = { + select: (selector: unknown) => selected.get(selector), + dispatch, + } as unknown as Store; + + await TestBed.configureTestingModule({ + imports: [SitesSharedModule], + declarations: [ShopProductsComponent], + providers: [ + { provide: Store, useValue: store }, + { provide: Actions, useValue: actions$ }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ShopProductsComponent); + fixture.detectChanges(); + }); + + it('loads the products as they open, and the products of the site switched to', () => { + expect(loadCount()).toBe(1); + + site$.next('second'); + + expect(loadCount()).toBe(2); + }); + + it("loads the products again as an entry's cart fields change", () => { + succeed( + new UpdateSectionEntryAction('blog/entry/shop/1/content/cartPrice', '25'), + ); + succeed( + new UpdateSectionEntryFromSyncAction( + 'blog/entry/shop/1/content/cartAttributes', + 'S, M', + ), + ); + succeed( + new SwapContentsSitesAction({ siteSlugFrom: 'blog', siteSlugTo: '' }), + ); + + expect(loadCount()).toBe(4); + + succeed( + new UpdateSectionEntryAction( + 'blog/entry/shop/1/content/description', + 'A poster', + ), + ); + + expect(loadCount()).toBe(4); + }); + + it('refreshes the stock from the button shown while products are listed', () => { + expect(refreshButton().textContent.trim()).toBe('Refresh stock'); + + refreshButton().click(); + + expect(loadCount()).toBe(2); + + products$.next([]); + fixture.detectChanges(); + + expect(refreshButton()).toBeNull(); + }); + + it('lists nothing before the products have loaded', () => { + products$.next(undefined); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.setting')).toBeNull(); + expect(refreshButton()).toBeNull(); + }); +}); diff --git a/editor/src/app/shop/products/shop-products.component.ts b/editor/src/app/shop/products/shop-products.component.ts index d5ec4f2b6..4fbb13b78 100644 --- a/editor/src/app/shop/products/shop-products.component.ts +++ b/editor/src/app/shop/products/shop-products.component.ts @@ -1,57 +1,124 @@ -import { Component, OnInit } from '@angular/core'; -import { Observable, combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Store } from '@ngxs/store'; +import { + Component, + DestroyRef, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable, combineLatest, merge } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { Actions, Store, ofActionSuccessful } from '@ngxs/store'; +import { AppState } from '../../app-state/app.state'; import { SiteSectionsState } from '../../sites/sections/sections-state/site-sections.state'; import { SectionTagsState } from '../../sites/sections/tags/section-tags.state'; import { SectionEntriesState } from '../../sites/sections/entries/entries-state/section-entries.state'; +import { + UpdateSectionEntryAction, + UpdateSectionEntryFromSyncAction, +} from '../../sites/sections/entries/entries-state/section-entries.actions'; +import { SwapContentsSitesAction } from '../../sites/sites-state/sites.actions'; +import { RefreshIndicator } from '../../shared/refresh-indicator'; import { ShopProductsState } from './shop-products.state'; -import { UpdateShopProductAction } from './shop-products.actions'; +import { + LoadShopProductsAction, + UpdateShopProductAction, +} from './shop-products.actions'; import { UpdateInputFocus } from '../../app-state/app.actions'; import { SiteSectionStateModel } from '../../sites/sections/sections-state/site-sections-state.model'; import { SectionTagsInterface } from '../../sites/sections/tags/section-tags-state.model'; import { SectionEntry } from '../../sites/sections/entries/entries-state/section-entries-state.model'; +/** The entry fields a product is made of: its price makes it one, its title and attributes name it */ +const CART_FIELDS = ['cartTitle', 'cartPrice', 'cartAttributes']; + +interface ProductGroup { + name: string; + title: string; + isTag: boolean; + hasProducts: boolean; + products: any[]; +} + @Component({ selector: 'berta-shop-products', template: ` - @for (group of productGroups$ | async; track group) { -
              -

              {{ group.title }}

              - @for (product of group.products; track product) { -
              - -

              - Reservations: {{ product.reservation }} -

              -
              - } -
              + @if (productGroups$ | async; as productGroups) { + @if (hasProducts(productGroups)) { +
              + +
              + } + @for (group of productGroups; track group) { +
              +

              {{ group.title }}

              + @for (product of group.products; track product) { +
              + +

              + Reservations: {{ product.reservation }} +

              +
              + } +
              + } } `, + host: { + // The listed products fade only while a slow load is still running + '[class.bt-sh-products-loading]': + 'loading.isLoading && loading.showsLoading', + '[attr.aria-busy]': 'loading.isLoading', + }, standalone: false, }) -export class ShopProductsComponent implements OnInit { - productGroups$: Observable< - { - name: string; - title: string; - isTag: boolean; - hasProducts: boolean; - products: any[]; - }[] - >; - - constructor(private store: Store) {} +export class ShopProductsComponent implements OnInit, OnDestroy { + productGroups$: Observable; + readonly loading = new RefreshIndicator(); + private destroyRef = inject(DestroyRef); + + constructor( + private store: Store, + private actions$: Actions, + ) {} ngOnInit() { + // Shoppers change the stock, and entries added or edited since make new products: the + // products load as the shop opens, for each site it shows, and as an entry's cart fields change + merge( + this.store.select(AppState.getSite).pipe(filter((site) => site !== null)), + this.actions$.pipe( + ofActionSuccessful( + UpdateSectionEntryFromSyncAction, + UpdateSectionEntryAction, + SwapContentsSitesAction, + ), + filter( + (action) => + action instanceof SwapContentsSitesAction || + CART_FIELDS.includes(action.path.split('/').pop()), + ), + ), + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.loadProducts()); + this.productGroups$ = combineLatest([ this.store.select(SiteSectionsState.getCurrentSiteShopSections), this.store.select(SectionTagsState.getCurrentSiteTags), @@ -65,7 +132,8 @@ export class ShopProductsComponent implements OnInit { SectionEntry[], any, ]) => { - let leftOverProducts = [...products]; + // None until the site's products have loaded + let leftOverProducts = [...(products ?? [])]; // 1. Add entry data to products let productData: any[] = entries.reduce((_productData, entry) => { @@ -181,6 +249,22 @@ export class ShopProductsComponent implements OnInit { ); } + ngOnDestroy() { + this.loading.destroy(); + } + + hasProducts(productGroups: ProductGroup[]): boolean { + return productGroups.some((group) => group.products.length > 0); + } + + /** A load started while another runs cancels the other */ + loadProducts() { + this.loading + .track(this.store.dispatch(new LoadShopProductsAction())) + // The service shows the error + .subscribe({ error: () => {} }); + } + updateProducts(field: string, value, id: string) { this.store.dispatch(new UpdateShopProductAction(id, { field, value })); } diff --git a/editor/src/app/shop/products/shop-products.state.spec.ts b/editor/src/app/shop/products/shop-products.state.spec.ts new file mode 100644 index 000000000..c6db12d06 --- /dev/null +++ b/editor/src/app/shop/products/shop-products.state.spec.ts @@ -0,0 +1,62 @@ +import { StateContext, Store } from '@ngxs/store'; +import { BehaviorSubject, of } from 'rxjs'; + +import { AppState } from '../../app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; +import { ShopState } from '../shop.state'; +import { + ShopProduct, + ShopProductsModel, + ShopProductsState, +} from './shop-products.state'; + +describe('ShopProductsState', () => { + const PRODUCTS: ShopProduct[] = [ + { id: '1', uniqid: 'abc', name: 'Poster', instock: 3, reservation: 1 }, + ]; + + let urls$: BehaviorSubject<{ [sectionSlug: string]: string }>; + let sync: jasmine.Spy; + let context: StateContext; + let state: ShopProductsState; + + beforeEach(() => { + urls$ = new BehaviorSubject({ products: '/products' }); + sync = jasmine + .createSpy('sync') + .and.returnValue(of({ message: '', data: { blog: PRODUCTS } })); + context = { + patchState: jasmine.createSpy('patchState'), + } as unknown as StateContext; + const store = { + selectSnapshot: (selector: unknown) => + selector === AppState.getSite ? 'blog' : undefined, + select: (selector: unknown) => + selector === ShopState.getURLs ? urls$ : undefined, + } as unknown as Store; + + state = new ShopProductsState(store, { + sync, + } as unknown as AppStateService); + }); + + it("replaces the current site's products with the loaded ones", () => { + state.loadProducts(context).subscribe(); + + expect(sync).toHaveBeenCalledWith('/products', { site: 'blog' }, 'GET'); + expect(context.patchState).toHaveBeenCalledWith({ blog: PRODUCTS }); + }); + + it("loads the products once the shop's URLs have come", () => { + urls$.next({}); + + state.loadProducts(context).subscribe(); + + expect(sync).not.toHaveBeenCalled(); + + urls$.next({ products: '/products' }); + + expect(sync).toHaveBeenCalledWith('/products', { site: 'blog' }, 'GET'); + expect(context.patchState).toHaveBeenCalledWith({ blog: PRODUCTS }); + }); +}); diff --git a/editor/src/app/shop/products/shop-products.state.ts b/editor/src/app/shop/products/shop-products.state.ts index 04692b175..6aa119955 100644 --- a/editor/src/app/shop/products/shop-products.state.ts +++ b/editor/src/app/shop/products/shop-products.state.ts @@ -1,44 +1,19 @@ -import { - State, - StateContext, - NgxsOnInit, - Selector, - Action, - Actions, - Store, - ofActionSuccessful, -} from '@ngxs/store'; -import { ShopStateService } from '../shop-state.service'; -import { - take, - tap, - catchError, - filter, - switchMap, - distinct, - map, -} from 'rxjs/operators'; +import { State, StateContext, Selector, Action, Store } from '@ngxs/store'; +import { take, tap, catchError, filter, switchMap } from 'rxjs/operators'; import { AppState } from '../../app-state/app.state'; import { UpdateShopProductAction, RenameShopProductSiteAction, DeleteShopProductSiteAction, ResetShopProductsAction, - InitShopProductsAction, + LoadShopProductsAction, } from './shop-products.actions'; -import { - UpdateSectionEntryAction, - UpdateSectionEntryFromSyncAction, -} from '../../sites/sections/entries/entries-state/section-entries.actions'; import { AppStateService } from '../../app-state/app-state.service'; import { ShopState } from '../shop.state'; import { HttpErrorResponse } from '@angular/common/http'; -import { DestroyRef, inject, Injectable } from '@angular/core'; -import { combineLatest, startWith } from 'rxjs'; -import { SwapContentsSitesAction } from 'src/app/sites/sites-state/sites.actions'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Injectable } from '@angular/core'; -interface ShopProduct { +export interface ShopProduct { id: string; uniqid: string; name: string; @@ -46,7 +21,7 @@ interface ShopProduct { reservation: number; } -interface ShopProductsModel { +export interface ShopProductsModel { [site: string]: Array; } @@ -57,7 +32,7 @@ const defaultState: ShopProductsModel = {}; defaults: defaultState, }) @Injectable() -export class ShopProductsState implements NgxsOnInit { +export class ShopProductsState { @Selector([ShopProductsState, AppState.getSite]) static getCurrentSiteProducts( state: ShopProductsModel, @@ -66,68 +41,38 @@ export class ShopProductsState implements NgxsOnInit { return state[site]; } - private destroyRef = inject(DestroyRef); - private counter = 0; - constructor( private store$: Store, - private actions$: Actions, private appStateService: AppStateService, - private stateService: ShopStateService, ) {} - ngxsOnInit({ dispatch }: StateContext) { - combineLatest([ - this.store$.select(AppState.getSite), - this.actions$.pipe( - ofActionSuccessful( - UpdateSectionEntryFromSyncAction, - UpdateSectionEntryAction, - SwapContentsSitesAction, - ), - filter((action) => { - // Skip filter for SwapContentsSitesAction - if (action instanceof SwapContentsSitesAction) { - return true; - } - // Apply filter for entry update actions - const actions = ['cartTitle', 'cartPrice', 'cartAttributes']; - const prop = action.path.split('/').pop(); - return actions.indexOf(prop) > -1; - }), - startWith(null), // Emit initial value so combineLatest starts immediately - ), - ]) - .pipe( - filter(([site]) => site !== null), - map(([site, action]) => ({ - site, - forceRefresh: action !== null, - })), - distinct(({ site, forceRefresh }) => - forceRefresh ? this.counter++ : site, - ), - switchMap(({ site, forceRefresh }) => - this.stateService - .getInitialState(site, 'products', forceRefresh) - .pipe( - take(1), - map((products) => ({ site, products })), - ), - ), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(({ site, products }) => { - dispatch(new InitShopProductsAction({ [site]: products[site] })); - }); - } + /** + * The products endpoint adds the rows of products for sale since, so new products are listed. + * Cancels the load still running, so a slower earlier response can't replace the newer stock. + */ + @Action(LoadShopProductsAction, { cancelUncompleted: true }) + loadProducts({ patchState }: StateContext) { + const site = this.store$.selectSnapshot(AppState.getSite); - @Action(InitShopProductsAction) - initShopProducts( - { patchState }: StateContext, - action: InitShopProductsAction, - ) { - patchState(action.payload); + // The shop's URLs come with its state, still loading in an editor just opened on the shop + return this.store$.select(ShopState.getURLs).pipe( + filter((urls) => !!urls.products), + take(1), + switchMap((urls) => + this.appStateService.sync(urls.products, { site }, 'GET'), + ), + tap((response: { message: string; data: ShopProductsModel }) => { + 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(UpdateShopProductAction) @@ -137,7 +82,6 @@ export class ShopProductsState implements NgxsOnInit { ) { const currentSite = this.store$.selectSnapshot(AppState.getSite); const syncURLs = this.store$.selectSnapshot(ShopState.getURLs); - const state = getState(); return this.appStateService .sync( @@ -150,8 +94,9 @@ export class ShopProductsState implements NgxsOnInit { ) .pipe( tap((response) => { + // The products as they are now: a load may have replaced them during the save patchState({ - [currentSite]: state[currentSite].map((product) => { + [currentSite]: getState()[currentSite].map((product) => { if (product.id !== action.id) { return product; } diff --git a/editor/src/app/shop/regional-costs/shop-regional-costs.component.ts b/editor/src/app/shop/regional-costs/shop-regional-costs.component.ts index 243954034..506363ff6 100644 --- a/editor/src/app/shop/regional-costs/shop-regional-costs.component.ts +++ b/editor/src/app/shop/regional-costs/shop-regional-costs.component.ts @@ -120,6 +120,14 @@ import { + } @empty { +
              +

              + Required. The shopping cart takes orders once the shop has a + shipping region. For free shipping or pickup, add a region without + costs. For prices without VAT, leave VAT empty. +

              +
              }

              Add region

              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', diff --git a/editor/src/app/shop/settings/shop-settings-config.state.spec.ts b/editor/src/app/shop/settings/shop-settings-config.state.spec.ts new file mode 100644 index 000000000..ec0da8bda --- /dev/null +++ b/editor/src/app/shop/settings/shop-settings-config.state.spec.ts @@ -0,0 +1,97 @@ +import { TestBed } from '@angular/core/testing'; +import { StateContext, Store } from '@ngxs/store'; +import { BehaviorSubject, of } from 'rxjs'; + +import { + ShopSettingsConfigModel, + ShopSettingsConfigState, +} from './shop-settings-config.state'; +import { InitShopSettingsConfigAction } from './shop-settings.actions'; +import { AppState } from '../../app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; +import { SiteSettingsState } from '../../sites/settings/site-settings.state'; +import { ShopState } from '../shop.state'; +import { ShopStateService } from '../shop-state.service'; + +describe('ShopSettingsConfigState', () => { + const settingsConfig = (title: string, emailDescription: string) => ({ + group_config: { + _: { title }, + email: { + format: 'text', + default: '', + title: 'Email', + description: emailDescription, + }, + }, + }); + + let language: BehaviorSubject; + let sync: jasmine.Spy; + let dispatch: jasmine.Spy; + + beforeEach(() => { + language = new BehaviorSubject('en'); + sync = jasmine.createSpy('sync').and.returnValue( + of({ + message: '', + data: settingsConfig( + 'Konfigurācijas vērtības', + 'Obligāts. Tava e-pasta adrese, uz kuru veikals sūta katru jauno pasūtījumu.', + ), + }), + ); + dispatch = jasmine.createSpy('dispatch'); + const selections = new Map([ + [AppState.getSite, of('')], + [SiteSettingsState.getCurrentSiteLanguage, language], + [ShopState.getURLs, of({ settingsConfig: '/settings-config' })], + ]); + const store = { + select: (selector: unknown) => selections.get(selector), + } as unknown as Store; + const stateService = { + getInitialState: () => + of( + settingsConfig( + 'Configuration values', + 'Required. Your email address, where the shop sends each new order.', + ), + ), + } as unknown as ShopStateService; + + const state = TestBed.runInInjectionContext( + () => + new ShopSettingsConfigState(store, stateService, { + sync, + } as unknown as AppStateService), + ); + state.ngxsOnInit({ + dispatch, + } as unknown as StateContext); + }); + + it('loads the settings config again in the language the site switches to', () => { + language.next('lv'); + + expect(sync).toHaveBeenCalledOnceWith( + '/settings-config', + { language: 'lv' }, + 'GET', + ); + const action = dispatch.calls.mostRecent().args[0]; + expect(action).toBeInstanceOf(InitShopSettingsConfigAction); + expect(action.payload.group_config._.title).toBe('Konfigurācijas vērtības'); + expect(action.payload.group_config.email.description).toBe( + 'Obligāts. Tava e-pasta adrese, uz kuru veikals sūta katru jauno pasūtījumu.', + ); + }); + + it('loads nothing again while the language stays the same', () => { + language.next('en'); + + expect(sync).not.toHaveBeenCalled(); + // Only the first load, with the shop state + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/editor/src/app/shop/settings/shop-settings-config.state.ts b/editor/src/app/shop/settings/shop-settings-config.state.ts index 00382cb45..c97e1238c 100644 --- a/editor/src/app/shop/settings/shop-settings-config.state.ts +++ b/editor/src/app/shop/settings/shop-settings-config.state.ts @@ -1,4 +1,4 @@ -import { take, filter, switchMap } from 'rxjs/operators'; +import { take, filter, switchMap, pairwise } from 'rxjs/operators'; import { State, StateContext, NgxsOnInit, Action, Store } from '@ngxs/store'; import { ShopStateService } from '../shop-state.service'; import { initSettingConfigGroup } from '../../shared/helpers'; @@ -8,6 +8,9 @@ import { } from './shop-settings.actions'; import { Injectable } from '@angular/core'; import { AppState } from 'src/app/app-state/app.state'; +import { AppStateService } from '../../app-state/app-state.service'; +import { SiteSettingsState } from '../../sites/settings/site-settings.state'; +import { ShopState } from '../shop.state'; export interface ShopSettingsConfigModel { [site: string]: any; @@ -24,6 +27,7 @@ export class ShopSettingsConfigState implements NgxsOnInit { constructor( private store$: Store, private stateService: ShopStateService, + private appStateService: AppStateService, ) {} ngxsOnInit({ dispatch }: StateContext) { @@ -39,16 +43,35 @@ export class ShopSettingsConfigState implements NgxsOnInit { ), ) .subscribe((settingsConfig) => { - const settingGroups = {}; - - for (const groupSlug in settingsConfig) { - settingGroups[groupSlug] = initSettingConfigGroup( - settingsConfig[groupSlug], - ); - delete settingGroups[groupSlug][groupSlug]; - } + dispatch( + new InitShopSettingsConfigAction(this.configGroups(settingsConfig)), + ); + }); - dispatch(new InitShopSettingsConfigAction(settingGroups)); + // Listen for language change + this.store$ + .select(SiteSettingsState.getCurrentSiteLanguage) + .pipe( + pairwise(), + filter(([prevLang, lang]) => !!prevLang && !!lang && prevLang !== lang), + switchMap(([, language]) => + this.store$.select(ShopState.getURLs).pipe( + filter((urls) => !!urls?.settingsConfig), + take(1), + switchMap((urls) => + this.appStateService.sync( + urls.settingsConfig, + { language }, + 'GET', + ), + ), + ), + ), + ) + .subscribe((response) => { + dispatch( + new InitShopSettingsConfigAction(this.configGroups(response.data)), + ); }); } @@ -64,4 +87,17 @@ export class ShopSettingsConfigState implements NgxsOnInit { resetShopSettingsConfig({ setState }: StateContext) { setState(defaultState); } + + private configGroups(settingsConfig) { + const settingGroups = {}; + + for (const groupSlug in settingsConfig) { + settingGroups[groupSlug] = initSettingConfigGroup( + settingsConfig[groupSlug], + ); + delete settingGroups[groupSlug][groupSlug]; + } + + return settingGroups; + } } 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) { group.slug === 'group_config') + ?.settings ?? []; + const value = (slug: string) => + String( + configSettings.find((setting) => setting.slug === slug)?.value ?? '', + ).trim(); + + return value('invoices') === 'yes' && value('sellerDetails') !== ''; + } + private destroyRef = inject(DestroyRef); private counter = 0; diff --git a/editor/src/app/shop/shop-cart-render.service.spec.ts b/editor/src/app/shop/shop-cart-render.service.spec.ts new file mode 100644 index 000000000..aff1d3ae0 --- /dev/null +++ b/editor/src/app/shop/shop-cart-render.service.spec.ts @@ -0,0 +1,270 @@ +import { ShopCartRenderService } from './shop-cart-render.service'; +import { TwigTemplateRenderService } from '../render/twig-template-render.service'; +import { SiteSectionStateModel } from '../sites/sections/sections-state/site-sections-state.model'; + +describe('ShopCartRenderService', () => { + // 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" 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="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: '', + }, + vatNumber: { + attributes: + ' class="xNgEditable xProperty-cartTextVatNo xCaption-VAT+no." data-path="blog/settings/siteTexts/cartTextVatNo" data-empty-caption="VAT 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: + ' 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: '', + }, + cardPayment: { + attributes: + ' class="xNgEditable xProperty-cartTextCardPayment xCaption-Debit+or+credit+card" data-path="blog/settings/siteTexts/cartTextCardPayment" data-empty-caption="Debit or credit card"', + content: '', + }, + paypalPayment: { + attributes: + ' class="xNgEditable xProperty-cartTextPaypalPayment xCaption-PayPal" data-path="blog/settings/siteTexts/cartTextPaypalPayment" data-empty-caption="PayPal"', + content: '', + }, + manualTransfer: { + attributes: + ' class="xNgEditable xProperty-cartManualTransfer xCaption-Bank+transfer" data-path="blog/settings/siteTexts/cartManualTransfer" data-empty-caption="Bank 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, + config: 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', + promoCode: 'SAVE10', + promoCodeDiscount: '10', + paymentMethod: 'both', + ...config, + }, + }, + [], + ); + + 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'); + }); + + it('shows the promo code entry only for a promo code with a discount', () => { + expect(renderCartViewData({}).discountAvailable).toBeTrue(); + expect( + renderCartViewData({}, { promoCode: '' }).discountAvailable, + ).toBeFalse(); + expect( + renderCartViewData({}, { promoCodeDiscount: '' }).discountAvailable, + ).toBeFalse(); + }); +}); diff --git a/editor/src/app/shop/shop-cart-render.service.ts b/editor/src/app/shop/shop-cart-render.service.ts index 443c28e2a..d6181d6d8 100644 --- a/editor/src/app/shop/shop-cart-render.service.ts +++ b/editor/src/app/shop/shop-cart-render.service.ts @@ -3,6 +3,234 @@ 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; +} + +/** + * 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, + }, + 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', + }, + 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, + }, + vatNumber: { + setting: 'cartTextVatNo', + default: 'VAT no.', + caption: 'VAT 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: 'Shipping 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', + }, + paymentDescription: { + setting: 'cartTextPaymentDescription', + default: '', + caption: 'Payment description', + }, + cardPayment: { + setting: 'cartTextCardPayment', + default: 'Debit or credit card', + caption: 'Debit or credit card', + siteOnly: true, + }, + paypalPayment: { + setting: 'cartTextPaypalPayment', + default: 'PayPal', + caption: 'PayPal', + siteOnly: true, + }, + manualTransfer: { + setting: 'cartManualTransfer', + default: 'Bank transfer', + caption: 'Bank 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 +322,16 @@ 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) { + getEditableTextData(siteSlug: string, siteSettings, text: EditableText) { return { attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextShippingTo', - 'xCaption-shipping+to', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextShippingTo`, - 'data-empty-caption': 'shipping to', + class: `xNgEditable xProperty-${text.setting} xCaption-${text.caption.replace(/ /g, '+')}`, + 'data-path': `${siteSlug}/settings/siteTexts/${text.setting}`, + 'data-empty-caption': text.caption, }), - 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) { - return { - attributes: toHtmlAttributes({ - class: [ - 'xNgEditable', - 'xProperty-cartTextNameSurname', - 'xCaption-name+surname', - ].join(' '), - 'data-path': `${siteSlug}/settings/siteTexts/cartTextNameSurname`, - 'data-empty-caption': 'name surname', - }), - 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 +347,32 @@ 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), + // Only a code with a discount gives the customer something to enter + discountAvailable: + String(shopSettings.group_config.promoCode ?? '').trim() !== '' && + parseInt(shopSettings.group_config.promoCodeDiscount) > 0, 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), + : 'Total', }; try { diff --git a/editor/src/app/shop/shop-state.service.ts b/editor/src/app/shop/shop-state.service.ts index a65739524..5d0fe498f 100644 --- a/editor/src/app/shop/shop-state.service.ts +++ b/editor/src/app/shop/shop-state.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { Observable, combineLatest } from 'rxjs'; +import { Observable, combineLatest, from } from 'rxjs'; import { take, filter, @@ -12,6 +12,7 @@ import { pairwise, shareReplay, catchError, + finalize, } from 'rxjs/operators'; import { Store } from '@ngxs/store'; @@ -116,6 +117,55 @@ export class ShopStateService { return this.cachedSectionStates[site]; } + /** + * An order's invoice PDF. When the shop has none, e.g. invoices are turned off, its message is shown. + */ + getOrderInvoice( + ordersUrl: string, + site: string, + orderId: number, + ): Observable { + const user = this.store.selectSnapshot((state) => state.user); + this.store.dispatch(new AppShowLoading()); + + return this.http + .get(`${ordersUrl}/${orderId}/invoice`, { + headers: { 'X-Authorization': 'Bearer ' + user.token }, + params: site ? { site: site } : {}, + responseType: 'blob', + }) + .pipe( + finalize(() => this.store.dispatch(new AppHideLoading())), + catchError((error: HttpErrorResponse) => { + // An error's JSON arrives as a blob too + const body: Promise = + error.error instanceof Blob + ? error.error.text() + : Promise.resolve(''); + + return from(body).pipe( + map((text) => { + let message = ''; + try { + message = JSON.parse(text).message; + } catch { + // Not the API's JSON, the HTTP error's message is shown + } + this.showError( + { + status: error.status, + error: { message }, + message: error.message, + }, + {}, + ); + throw error; + }), + ); + }), + ); + } + private showError(error: any, data: any) { let message = 'Oops unexpected error!'; 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/editor/src/app/sites/shared/icon-download.component.ts b/editor/src/app/sites/shared/icon-download.component.ts new file mode 100644 index 000000000..4bdd15bec --- /dev/null +++ b/editor/src/app/sites/shared/icon-download.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'bt-icon-download', + template: ` + + + + + `, + standalone: false, +}) +export class IconDownloadComponent {} 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/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..a929bd827 100644 --- a/editor/src/app/sites/shared/sites-shared.module.ts +++ b/editor/src/app/sites/shared/sites-shared.module.ts @@ -28,6 +28,9 @@ 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'; +import { IconDownloadComponent } from './icon-download.component'; +import { IconEditComponent } from './icon-edit.component'; @NgModule({ imports: [ @@ -62,6 +65,9 @@ import { IconSwapComponent } from './icon-switch.component'; IconPublishComponent, IconMoveComponent, IconSwapComponent, + IconRefreshComponent, + IconDownloadComponent, + IconEditComponent, HelpTooltipComponent, ], exports: [ @@ -88,6 +94,9 @@ import { IconSwapComponent } from './icon-switch.component'; IconPublishComponent, IconMoveComponent, IconSwapComponent, + IconRefreshComponent, + IconDownloadComponent, + IconEditComponent, HelpTooltipComponent, ], }) diff --git a/editor/src/app/themes/themes.component.ts b/editor/src/app/themes/themes.component.ts index c0478e2fc..137dcc978 100644 --- a/editor/src/app/themes/themes.component.ts +++ b/editor/src/app/themes/themes.component.ts @@ -111,7 +111,7 @@ export class ThemesComponent implements OnInit { next: () => { const url = window.location.origin + - (this.appState.site ? '/' + this.appState.site : '') + + (this.appState.site ? '/' + this.appState.site + '/' : '/') + '?preview=1'; window.open(url, '_blank'); }, diff --git a/editor/src/styles/_aside.scss b/editor/src/styles/_aside.scss index 0bf39f16a..62826068a 100644 --- a/editor/src/styles/_aside.scss +++ b/editor/src/styles/_aside.scss @@ -173,6 +173,9 @@ aside { > .settings { display: grid; + // Never wider than the sidebar: an expanded section's content overflows visibly, + // so an auto column would grow to fit an unwrappable line, e.g. a long select label + grid-template-columns: minmax(0, 1fr); grid-template-rows: 0fr; transition: grid-template-rows 0.25s ease; diff --git a/editor/src/styles/_shop.scss b/editor/src/styles/_shop.scss index dc24a5952..774898c5a 100644 --- a/editor/src/styles/_shop.scss +++ b/editor/src/styles/_shop.scss @@ -1,3 +1,4 @@ +@use "sass:color"; @use "variables"; berta-shop { @@ -46,6 +47,41 @@ berta-shop-orders { width: 50%; } } + + // A text button with an icon: a list's Refresh, and an order's "Download invoice" and "Add private notes" + .bt-sh-refresh, + .bt-sh-order-download-invoice, + .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; + + path { + fill: currentColor; + } + } + + &:hover { + color: variables.$berta-accent; + } + + &[disabled] { + color: variables.$grey-40; + cursor: default; + } + } } berta-shop-orders { @@ -57,6 +93,12 @@ berta-shop-orders { } berta-shop-products { + .bt-sh-products-toolbar { + display: flex; + justify-content: flex-end; + margin: 1.3em; + } + .product { &:not(:last-child) { border-bottom: 1px solid variables.$grey-10; @@ -70,6 +112,15 @@ berta-shop-products { padding-top: 0; } } + + // A slow load: the listed products fade until the loaded ones arrive + > .setting { + transition: opacity 0.2s; + } + + &.bt-sh-products-loading > .setting { + opacity: 0.5; + } } .bt-sh-region { @@ -215,4 +266,203 @@ berta-shop-orders { padding-left: 0; } } + + .bt-sh-orders-toolbar { + 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; + + p { + color: variables.$grey-40; + margin: 0; + } + + .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; + } + } + } + } + + // A slow load: the listed orders fade until the next ones arrive + > .setting, + > .bt-sh-orders-empty, + > .bt-sh-orders-pager { + transition: opacity 0.2s; + } + + &.bt-sh-orders-loading { + > .setting, + > .bt-sh-orders-empty, + > .bt-sh-orders-pager { + opacity: 0.5; + } + } + + // Apart from "Add private notes" below it + .bt-sh-order-download-invoice { + margin-bottom: 1em; + } + + .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; + } + } + + // 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; + + p { + color: variables.$grey-40; + margin: 0; + } + } + + .bt-sh-orders-pager { + align-items: center; + display: flex; + justify-content: space-between; + margin: 1.3em; + + p { + margin: 0 0.5em; + text-align: center; + } + } } diff --git a/editor/src/styles/_utils.scss b/editor/src/styles/_utils.scss index f4d336fdf..e901f72e4 100644 --- a/editor/src/styles/_utils.scss +++ b/editor/src/styles/_utils.scss @@ -1,3 +1,15 @@ .hidden { display: none; } + +// The refresh icon of a button loading through a RefreshIndicator: a half turn in its +// REFRESH_ICON_HALF_TURN +@keyframes bt-refresh-spin { + to { + transform: rotate(180deg); + } +} + +.bt-loading bt-icon-refresh svg { + animation: bt-refresh-spin 0.5s linear infinite; +} diff --git a/engine/_classes/class.berta.php b/engine/_classes/class.berta.php index 303a6ee34..d91d563aa 100644 --- a/engine/_classes/class.berta.php +++ b/engine/_classes/class.berta.php @@ -1,7 +1,6 @@ settings = new Settings($setttingsDefaults); // general site-wide settings - I18n::load_language($this->settings->get('language', 'language')); - $templateName = $this->settings->get('template', 'template', true); $this->template = new BertaTemplate($templateName, $this->settings, $this->security->userLoggedIn); @@ -64,8 +61,9 @@ public function initContent($sectionName, $tagName) if ($this->environment == 'engine') { $sectionName = current(array_keys($this->sections)); } else { + // The cart section is the shop's cart dialog, no page of its own foreach ($this->sections as $sName => $s) { - if (! empty($s['@attributes']['published'])) { + if (! empty($s['@attributes']['published']) && ! self::isCartSection($s)) { $sectionName = $sName; break; } @@ -106,4 +104,20 @@ public function output() { return $this->template->output(); } + + /** + * The site shows the current section's page. The shop's cart section is shown only in the + * editor, and in the cart dialog, which the shop script loads with ?shopping_cart=1. + */ + public function isSectionPageShown() + { + return $this->environment == 'engine' + || ! self::isCartSection($this->sections[$this->sectionName] ?? []) + || ! empty($_GET['shopping_cart']); + } + + private static function isCartSection($section) + { + return ($section['@attributes']['type'] ?? null) == 'shopping_cart'; + } } diff --git a/engine/_classes/class.bertatemplate.php b/engine/_classes/class.bertatemplate.php index b782dccca..a27b3d62a 100644 --- a/engine/_classes/class.bertatemplate.php +++ b/engine/_classes/class.bertatemplate.php @@ -57,10 +57,13 @@ public function load($templateName, $generalSettingsInstance = false) $templateName = 'default'; } } - // save in settings - $settings = new Settings(false); - $settings->update('template', 'template', ['value' => $templateName]); - $settings->save(); + // Remember the default template, once there is a storage folder to keep it in. + // Until then the setup wizard reports the missing folder. + if (is_writable(self::$options['XML_ROOT'])) { + $settings = new Settings(false); + $settings->update('template', 'template', ['value' => $templateName]); + $settings->save(); + } } $this->name = $templateName; @@ -85,7 +88,7 @@ public function load($templateName, $generalSettingsInstance = false) } if (file_exists($tPath) && file_exists($tPath . '/template.conf.php')) { - [, $settingsDefinition] = include $tPath . '/template.conf.php'; + [, $settingsDefinition] = (include $tPath . '/template.conf.php')(bertaUntranslated(...), ! empty($GLOBALS['shopEnabled'])); $this->settings = new Settings($settingsDefinition, $generalSettingsInstance, $this->name); @@ -104,12 +107,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(); diff --git a/engine/_classes/class.i18n.php b/engine/_classes/class.i18n.php deleted file mode 100644 index 1f607e06c..000000000 --- a/engine/_classes/class.i18n.php +++ /dev/null @@ -1,30 +0,0 @@ -initContent($sectionName, $tagName); -if ($querySectionName && $querySectionName != 'sitemap.xml' && $berta->sectionName != $querySectionName) { +if (($querySectionName && $querySectionName != 'sitemap.xml' && $berta->sectionName != $querySectionName) || ! $berta->isSectionPageShown()) { include dirname(__DIR__) . '/error/404.php'; exit; } diff --git a/engine/inc.engineprefs.php b/engine/inc.engineprefs.php index 3631d307e..be1323609 100644 --- a/engine/inc.engineprefs.php +++ b/engine/inc.engineprefs.php @@ -20,13 +20,6 @@ */ require 'inc.version.php'; -/** - * Interface language (I18n) - * A file named like the value of this option must be placed into engine/lang folder, containing all translations - */ -$options['default_language'] = 'en'; -$options['languages'] = ['en' => 'English', 'lv' => 'Latviešu', 'fr' => 'Français', 'ru' => 'Русский', 'nl' => 'Nederlands', 'pl' => 'Polski', 'es' => 'Spanish']; - // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -102,15 +95,6 @@ */ require 'inc.hosting.php'; -$options['images']['small_width'] = 200; -$options['images']['small_height'] = 200; - -$options['images']['medium_width'] = 400; -$options['images']['medium_height'] = 400; - -$options['images']['large_width'] = 600; -$options['images']['large_height'] = 600; - // Row gallery image limit returned from server // Remaining images will be rendered in frontend $options['row_gallery_image_limit'] = [ diff --git a/engine/inc.error_handling.php b/engine/inc.error_handling.php index 57c1d7a41..cb6f7f0b8 100644 --- a/engine/inc.error_handling.php +++ b/engine/inc.error_handling.php @@ -1,10 +1,13 @@ title], and the same + * list with the choice to inherit the general font settings + * + * @return array{fonts: array, withInherit: array} + */ +return (static function (): array { + $fonts = [ + 'Arial, sans-serif' => 'Arial, sans-serif', + 'Helvetica, Arial, sans-serif' => 'Helvetica, Arial, sans-serif', + '"Helvetica Neue", Helvetica, Arial, sans-serif' => 'Helvetica Neue, Helvetica, Arial, sans-serif', + '"Arial Black", Gadget, sans-serif' => 'Arial Black, Gadget', + '"Comic Sans MS", cursive' => 'Comic Sans MS', + '"Courier New", Courier, monospace' => 'Courier New, Courier', + 'Georgia, "Times New Roman", Times, serif' => 'Georgia, Times New Roman, Times', + 'Impact, Charcoal, sans-serif' => 'Impact, Charcoal', + '"Lucida Console", Monaco, monospace' => 'Lucida Console, Monaco', + '"Lucida Sans Unicode", "Lucida Grande", sans-serif' => 'Lucida Sans Unicode, Lucida Grande', + '"Palatino Linotype", "Book Antiqua", Palatino, serif' => 'Palatino Linotype, Book Antiqua, Palatino', + 'Tahoma, Geneva, sans-serif' => 'Tahoma, Geneva', + '"Times New Roman", Times, serif' => 'Times New Roman, Times', + '"Trebuchet MS", Helvetica, sans-serif' => 'Trebuchet MS, Helvetica', + 'Verdana, Geneva, sans-serif' => 'Verdana, Geneva', + ]; + + return [ + 'fonts' => $fonts, + 'withInherit' => ['inherit' => '(inherit from general-font-settings)'] + $fonts, + ]; +})(); diff --git a/engine/inc.functions.php b/engine/inc.functions.php index edacc4531..6ebf88e23 100644 --- a/engine/inc.functions.php +++ b/engine/inc.functions.php @@ -60,3 +60,12 @@ function d($var) print_r($var); echo '
              '; } + +/** + * The translator the engine hands the settings definitions: it reads only their default values, + * so their titles and descriptions stay as written. The editor's API translates them. + */ +function bertaUntranslated(string $text): string +{ + return $text; +} diff --git a/engine/inc.page.php b/engine/inc.page.php index 3fc8d2653..cd306cf2a 100644 --- a/engine/inc.page.php +++ b/engine/inc.page.php @@ -1,9 +1,5 @@ > + */ +return static function (callable $translate, bool $isShopEnabled, ?int $hostingPlan): array { + $languages = [ + 'en' => 'English', + 'lv' => 'Latviešu', + 'fr' => 'Français', + 'ru' => 'Русский', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'es' => 'Spanish', + ]; + $images = [ + 'small_width' => 200, + 'small_height' => 200, + 'medium_width' => 400, + 'medium_height' => 400, + 'large_width' => 600, + 'large_height' => 600, + ]; + + $settingsDefinition = [ + 'berta' => [ + '_' => ['invisible' => true], + 'installed' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => null, + 'title' => '', + 'description' => '', + ], + ], + + // siteTexts block is NOT editable in the settings page. It is reserved for texts appearing only on the page + 'siteTexts' => [ + '_' => [ + 'title' => $translate('Texts in the website'), + 'invisible' => true, + ], + 'siteHeading' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => null, + 'title' => $translate('Site heading'), + 'description' => $translate('Name of your site. Visible in all sections.'), + ], + ], + + 'template' => [ + '_' => [ + 'title' => $translate('Template'), + ], + 'template' => [ + 'format' => 'select', + 'values' => 'templates', + 'allow_blank' => false, + 'default' => false, + 'affectsStyle' => true, + 'title' => $translate('Template'), + 'description' => $translate('Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.'), + ], + ], + + 'theme' => [ + '_' => [ + 'title' => $translate('Theme'), + ], + 'theme' => [ + 'format' => 'route', + 'default' => '/themes', + 'title' => $translate('Choose theme'), + 'description' => $translate('Themes are ready made sites with predefined design settings.'), + ], + ], + + 'texts' => [ + '_' => [ + 'title' => $translate('Texts'), + ], + 'ownerName' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => null, + 'title' => $translate('Your name'), + 'description' => $translate('Your name will be put in a meta-tag in the code of your site. You can choose any name ;)'), + ], + 'pageTitle' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => 'berta', + 'title' => $translate('Page title (title bar)'), + 'description' => $translate('Text that appears in the bowser title bar'), + ], + 'metaDescription' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => 'Personal portfolio built with Berta', + 'title' => $translate(' description'), + 'description' => $translate('Short site description. It should not be longer than one or two sentences.'), + ], + ], + + 'pageLayout' => [ + '_' => [ + 'title' => $translate('Page layout'), + ], + 'favicon' => [ + 'format' => 'icon', + 'default' => '', + 'allow_blank' => true, + 'title' => $translate('Favicon'), + 'description' => $translate('Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.'), + ], + 'gridStep' => [ + 'format' => 'text', + 'default' => 10, + 'allow_blank' => true, + 'validation' => 'positive_integer', + 'title' => $translate('Grid step'), + 'description' => $translate('Distance in pixels for snap-to-grid dragging.'), + ], + 'showGrid' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'yes', + 'no', + ], + 'title' => $translate('Show gridlines'), + 'description' => $translate('Choose "yes" to show gridlines in background. Available only in Messy template.'), + ], + 'gridColor' => [ + 'format' => 'select', + 'default' => 'black', + 'allow_blank' => true, + 'values' => [ + 'black', + 'white', + ], + 'title' => $translate('Gridlines color'), + 'description' => '', + ], + ], + + 'navigation' => [ + '_' => [ + 'title' => $translate('Navigation'), + ], + 'landingSectionVisible' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Is first section visible in menu?'), + 'description' => $translate('Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.'), + ], + 'landingSectionPageHeadingVisible' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Show page heading in first section?'), + 'description' => $translate('Choose "no" to hide page heading in first section.'), + ], + 'landingSectionMenuVisible' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Show menu in first section?'), + 'description' => $translate('Choose "no" to hide the menu in first section.'), + ], + 'alwaysSelectTag' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Always auto-select a submenu item?'), + 'description' => $translate('Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.'), + ], + 'backToTopEnabled' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Enable navigation to page top'), + 'description' => $translate('Shows navigation button at the bottom when page is scrolled.'), + ], + ], + + 'entryLayout' => [ + '_' => [ + 'title' => $translate('Entry layout'), + ], + 'galleryFullScreenSettings' => [ + 'format' => false, + 'default' => false, + 'title' => $translate('Gallery fullscreen settings:'), + ], + 'galleryFullScreenDefault' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => ['yes', 'no'], + 'affectsStyle' => true, + 'title' => $translate('Is enabled by default'), + 'description' => $translate('Enables gallery fullscreen mode for new entries.'), + ], + 'galleryFullScreenBackground' => [ + 'format' => 'select', + 'default' => 'black', + 'values' => [ + 'black', + 'white', + ], + 'affectsStyle' => true, + 'title' => $translate('Background color'), + 'description' => $translate('Color of the fullscreen gallery background.'), + ], + 'galleryFullScreenImageNumbers' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Image numbers'), + 'description' => $translate('Enables fullscreen gallery slide numbers.'), + ], + 'galleryFullScreenCaptionAlign' => [ + 'format' => 'select', + 'default' => 'left', + 'values' => [ + 'left', + 'right', + 'center', + ], + 'affectsStyle' => true, + 'title' => $translate('Caption alignment'), + 'description' => $translate('Positioning of the image caption text.'), + ], + 'group_gallery' => [ + 'format' => false, + 'default' => false, + 'title' => $translate('Image gallery appearance:'), + ], + 'gallerySlideshowAutoRewind' => [ + 'format' => 'select', + 'default' => 'no', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Slideshow loop mode'), + ], + 'gallerySlideNumberVisibilityDefault' => [ + 'format' => 'select', + 'default' => 'yes', + 'values' => [ + 'yes', + 'no', + ], + 'affectsStyle' => true, + 'title' => $translate('Show slideshow image numbers'), + 'description' => $translate('Set the default state of image number visibility in slideshow galleries.'), + ], + ], + + 'media' => [ + '_' => [ + 'title' => $translate('Media'), + ], + 'imagesSmallWidth' => [ + 'format' => 'text', + 'default' => $images['small_width'], + 'allow_blank' => true, + 'validation' => 'positive_integer', + 'affectsStyle' => true, + 'title' => $translate('Small image width'), + 'description' => $translate('Maximum size of a small image (visible if \'Small images\' are switched on in the gallery editor). These settings don\'t affect original image.'), + ], + 'imagesSmallHeight' => [ + 'format' => 'text', + 'default' => $images['small_height'], + 'allow_blank' => true, + 'validation' => 'positive_integer', + 'affectsStyle' => true, + 'title' => $translate('Small image height'), + 'description' => '', + ], + 'imagesMediumWidth' => [ + 'format' => 'text', + 'default' => $images['medium_width'], + 'allow_blank' => true, + 'validation' => 'positive_integer', + 'affectsStyle' => true, + 'title' => $translate('Medium image width'), + 'description' => $translate('Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.'), + ], + 'imagesMediumHeight' => [ + 'format' => 'text', + 'default' => $images['medium_height'], + 'validation' => 'positive_integer', + 'allow_blank' => true, + 'affectsStyle' => true, + 'title' => $translate('Medium image height'), + 'description' => '', + ], + 'imagesLargeWidth' => [ + 'format' => 'text', + 'default' => $images['large_width'], + 'allow_blank' => true, + 'css_units' => false, + 'validation' => 'positive_integer', + 'title' => $translate('Large image width'), + 'description' => $translate('Maximum size of a large image (visible if \'Large images\' are switched on in the gallery editor). These settings don\'t affect original image.'), + ], + 'imagesLargeHeight' => [ + 'format' => 'text', + 'default' => $images['large_height'], + 'allow_blank' => true, + 'css_units' => false, + 'affectsStyle' => true, + 'title' => $translate('Large image height'), + 'description' => '', + ], + ], + + 'socialMediaLinks' => [ + '_' => ['title' => $translate('Social media buttons')], + 'links' => [ + 'children' => [ + 'icon' => [ + 'format' => 'icon-readonly', + 'default' => 'link', + 'title' => $translate('Icon'), + 'placeholder' => $translate('Icon'), + 'description' => '', + ], + 'url' => [ + 'format' => 'url', + 'default' => '', + 'title' => $translate('Link'), + 'placeholder' => ('https://example.com'), + 'description' => '', + ], + ], + 'default' => [], + 'title' => $translate('Social media links'), + 'description' => '', + ], + 'location' => [ + 'format' => 'select', + 'allow_blank' => false, + 'default' => 'footer', + 'values' => [ + 'footer', + 'additionalText', + ], + 'affectsStyle' => true, + 'title' => $translate('Location'), + 'description' => $translate('Location of social media buttons. Content of additional text or additional footer will be replaced.'), + ], + ], + + 'socialMediaButtons' => [ + '_' => [ + 'title' => $translate('Custom social media buttons'), + ], + 'socialMediaHTML' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('HTML code'), + 'description' => $translate('Paste or write your HTML code here.'), + 'requires_feature' => 'custom_javascript', + ], + 'socialMediaJS' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Javascript code'), + 'description' => $translate('Paste or write your Javascript code here.'), + 'requires_feature' => 'custom_javascript', + ], + 'socialMediaLocation' => [ + 'format' => 'select', + 'allow_blank' => false, + 'default' => 'footer', + 'values' => [ + 'footer', + 'additionalText', + ], + 'affectsStyle' => true, + 'title' => $translate('Location'), + 'description' => $translate('Location of social media buttons. Content of additional text or additional footer will be replaced.'), + ], + ], + + 'banners' => [ + '_' => [ + 'title' => $translate('Banners'), + ], + 'banner1_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (1)', + 'description' => '' . $translate('banner_image_description') . '', + ], + 'banner1_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (1)', + 'description' => $translate('banner_link_description'), + ], + 'banner2_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (2)', + 'description' => '', + ], + 'banner2_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (2)', + 'description' => '', + ], + 'banner3_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (3)', + 'description' => '', + ], + 'banner3_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (3)', + 'description' => '', + ], + 'banner4_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (4)', + 'description' => '', + ], + 'banner4_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (4)', + 'description' => '', + ], + 'banner5_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (5)', + 'description' => '', + ], + 'banner5_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (5)', + 'description' => '', + ], + 'banner6_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (6)', + 'description' => '', + ], + 'banner6_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (6)', + 'description' => '', + ], + 'banner7_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (7)', + 'description' => '', + ], + 'banner7_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (7)', + 'description' => '', + ], + 'banner8_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (8)', + 'description' => '', + ], + 'banner8_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (8)', + 'description' => '', + ], + 'banner9_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (9)', + 'description' => '', + ], + 'banner9_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (9)', + 'description' => '', + ], + 'banner10_image' => [ + 'format' => 'image', + 'default' => '', + 'min_width' => 1, + 'min_height' => 1, + 'max_width' => 3000, + 'max_height' => 3000, + 'title' => $translate('Banner image') . ' (10)', + 'description' => '', + ], + 'banner10_link' => [ + 'format' => 'url', + 'allow_blank' => true, + 'default' => null, + 'link' => true, + 'title' => $translate('Banner link') . ' (10)', + 'description' => '', + ], + ], + + 'language' => [ + '_' => [ + 'title' => $translate('Language'), + ], + 'language' => [ + 'format' => 'select', + 'allow_blank' => false, + 'default' => 'en', + 'values' => $languages, + 'title' => $translate('Interface language'), + 'description' => $translate('language_description'), + ], + ], + + 'settings' => [ + '_' => [ + 'title' => $translate('Other settings'), + ], + 'googleAnalyticsId' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Google Analytics measurement ID'), + 'placeholder' => 'G-XXXXXXXXXX', + 'validator' => 'GoogleAnalytics', + 'description' => $translate('How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.'), + ], + 'googleTagManagerContainerId' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Google Tag Manager container ID'), + 'placeholder' => 'GTM-XXXXXX', + 'description' => $translate('How to get the container ID. Leave blank if Google Analytics measurement ID is filled.'), + ], + 'googleSiteVerification' => [ + 'format' => 'text', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Google site verification tag'), + 'description' => $translate('Google ownership verification tag. More info.'), + 'requires_feature' => 'custom_javascript', + ], + 'jsInclude' => [ + 'format' => 'longtext', + 'allow_blank' => true, + 'default' => '', + 'html_entities' => true, + 'title' => $translate('Javascript include'), + 'description' => $translate('js_include_description'), + 'requires_feature' => 'custom_javascript', + ], + ], + ]; + + $shopSettingsPath = dirname(__DIR__) . '/_plugin_shop/inc.setting-definition.php'; + if ($isShopEnabled && is_file($shopSettingsPath)) { + $settingsDefinition['shop'] = (require $shopSettingsPath)($translate); + } + + // Berta hosting plans above the basic one can hide Berta's copyright + if ($hostingPlan > 1) { + $settingsDefinition['settings']['hideBertaCopyright'] = [ + 'format' => 'select', + 'default' => 'no', + 'values' => ['yes', 'no'], + 'title' => $translate('Hide copyrights'), + 'description' => $translate('Hides Berta\'s copyrights'), + ]; + } + + return $settingsDefinition; +}; diff --git a/engine/inc.settings.php b/engine/inc.settings.php index 1a79e49f1..e3bc424c7 100644 --- a/engine/inc.settings.php +++ b/engine/inc.settings.php @@ -1,706 +1,22 @@ 'English', - 'lv' => 'Latviešu', - 'fr' => 'Français', - 'ru' => 'Русский', - 'nl' => 'Nederlands', - 'pl' => 'Polski', - 'es' => 'Spanish', -]; -$options['images'] = isset($options['images']) ? $options['images'] : []; -$options['images'] = array_merge($options['images'], [ - 'small_width' => 200, - 'small_height' => 200, - 'medium_width' => 400, - 'medium_height' => 400, - 'large_width' => 600, - 'large_height' => 600, -]); +/** + * The site engine's settings: the definitions' default values, and the limits of the site's hosting plan. + * The engine reads only the default values, so the definitions' texts stay untranslated here. + */ -$settingsFontSelectGeneral = [ - '"Helvetica Neue", Helvetica, Arial, sans-serif' => 'Helvetica Neue, Helvetica, Arial, sans-serif', - 'Helvetica, Arial, sans-serif' => 'Helvetica, Arial, sans-serif', - '"Arial Black", Gadget, sans-serif' => 'Arial Black, Gadget', - '"Comic Sans MS", cursive' => 'Comic Sans MS', - '"Courier New", Courier, monospace' => 'Courier New, Courier', - 'Georgia, "Times New Roman", Times, serif' => 'Georgia, Times New Roman, Times', - 'Impact, Charcoal, sans-serif' => 'Impact, Charcoal', - '"Lucida Console", Monaco, monospace' => 'Lucida Console, Monaco', - '"Lucida Sans Unicode", "Lucida Grande", sans-serif' => 'Lucida Sans Unicode, Lucida Grande', - '"Palatino Linotype", "Book Antiqua", Palatino, serif' => 'Palatino Linotype, Book Antiqua, Palatino', - 'Tahoma, Geneva, sans-serif' => 'Tahoma, Geneva', - '"Times New Roman", Times, serif' => 'Times New Roman, Times', - '"Trebuchet MS", Helvetica, sans-serif' => 'Trebuchet MS, Helvetica', - 'Verdana, Geneva, sans-serif' => 'Verdana, Geneva', -]; +use App\User\UserModel; -$settingsFontSelect = array_merge(['inherit' => '(inherit from general-font-settings)'], $settingsFontSelectGeneral); - -$settingsDefinition = [ - 'berta' => [ - '_' => ['invisible' => true], - 'installed' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => null, - 'title' => '', - 'description' => '', - ], - ], - - // siteTexts block is NOT editable in the settings page. It is reserved for texts appearing only on the page - 'siteTexts' => [ - '_' => [ - 'title' => I18n::_('Texts in the website'), - 'invisible' => true, - ], - 'siteHeading' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => null, - 'title' => I18n::_('Site heading'), - 'description' => I18n::_('Name of your site. Visible in all sections.'), - ], - ], - - 'template' => [ - '_' => [ - 'title' => I18n::_('Template'), - ], - 'template' => [ - 'format' => 'select', - 'values' => 'templates', - 'allow_blank' => false, - 'default' => false, - 'affectsStyle' => true, - 'title' => I18n::_('Template'), - 'description' => I18n::_('Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.'), - ], - ], - - 'theme' => [ - '_' => [ - 'title' => I18n::_('Theme'), - ], - 'theme' => [ - 'format' => 'route', - 'default' => '/themes', - 'title' => I18n::_('Choose theme'), - 'description' => I18n::_('Themes are ready made sites with predefined design settings.'), - ], - ], - - 'texts' => [ - '_' => [ - 'title' => I18n::_('Texts'), - ], - 'ownerName' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => null, - 'title' => I18n::_('Your name'), - 'description' => I18n::_('Your name will be put in a meta-tag in the code of your site. You can choose any name ;)'), - ], - 'pageTitle' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => 'berta', - 'title' => I18n::_('Page title (title bar)'), - 'description' => I18n::_('Text that appears in the bowser title bar'), - ], - 'metaDescription' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => 'Personal portfolio built with Berta', - 'title' => I18n::_(' description'), - 'description' => I18n::_('Short site description. It should not be longer than one or two sentences.'), - ], - ], - - 'pageLayout' => [ - '_' => [ - 'title' => I18n::_('Page layout'), - ], - 'favicon' => [ - 'format' => 'icon', - 'default' => '', - 'allow_blank' => true, - 'title' => I18n::_('Favicon'), - 'description' => I18n::_('Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.'), - ], - 'gridStep' => [ - 'format' => 'text', - 'default' => 10, - 'allow_blank' => true, - 'validation' => 'positive_integer', - 'title' => I18n::_('Grid step'), - 'description' => I18n::_('Distance in pixels for snap-to-grid dragging.'), - ], - 'showGrid' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'yes', - 'no', - ], - 'title' => I18n::_('Show gridlines'), - 'description' => I18n::_('Choose "yes" to show gridlines in background. Available only in Messy template.'), - ], - 'gridColor' => [ - 'format' => 'select', - 'default' => 'black', - 'allow_blank' => true, - 'values' => [ - 'black', - 'white', - ], - 'title' => I18n::_('Gridlines color'), - 'description' => '', - ], - ], - - 'navigation' => [ - '_' => [ - 'title' => I18n::_('Navigation'), - ], - 'landingSectionVisible' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Is first section visible in menu?'), - 'description' => I18n::_('Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.'), - ], - 'landingSectionPageHeadingVisible' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Show page heading in first section?'), - 'description' => I18n::_('Choose "no" to hide page heading in first section.'), - ], - 'landingSectionMenuVisible' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Show menu in first section?'), - 'description' => I18n::_('Choose "no" to hide the menu in first section.'), - ], - 'alwaysSelectTag' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Always auto-select a submenu item?'), - 'description' => I18n::_('Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.'), - ], - 'backToTopEnabled' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Enable navigation to page top'), - 'description' => I18n::_('Shows navigation button at the bottom when page is scrolled.'), - ], - ], - - 'entryLayout' => [ - '_' => [ - 'title' => I18n::_('Entry layout'), - ], - 'galleryFullScreenSettings' => [ - 'format' => false, - 'default' => false, - 'title' => I18n::_('Gallery fullscreen settings:'), - ], - 'galleryFullScreenDefault' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => ['yes', 'no'], - 'affectsStyle' => true, - 'title' => I18n::_('Is enabled by default'), - 'description' => I18n::_('Enables gallery fullscreen mode for new entries.'), - ], - 'galleryFullScreenBackground' => [ - 'format' => 'select', - 'default' => 'black', - 'values' => [ - 'black', - 'white', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Background color'), - 'description' => I18n::_('Color of the fullscreen gallery background.'), - ], - 'galleryFullScreenImageNumbers' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Image numbers'), - 'description' => I18n::_('Enables fullscreen gallery slide numbers.'), - ], - 'galleryFullScreenCaptionAlign' => [ - 'format' => 'select', - 'default' => 'left', - 'values' => [ - 'left', - 'right', - 'center', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Caption alignment'), - 'description' => I18n::_('Positioning of the image caption text.'), - ], - 'group_gallery' => [ - 'format' => false, - 'default' => false, - 'title' => I18n::_('Image gallery appearance:'), - ], - 'gallerySlideshowAutoRewind' => [ - 'format' => 'select', - 'default' => 'no', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Slideshow loop mode'), - ], - 'gallerySlideNumberVisibilityDefault' => [ - 'format' => 'select', - 'default' => 'yes', - 'values' => [ - 'yes', - 'no', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Show slideshow image numbers'), - 'description' => I18n::_('Set the default state of image number visibility in slideshow galleries.'), - ], - ], - - 'media' => [ - '_' => [ - 'title' => I18n::_('Media'), - ], - 'imagesSmallWidth' => [ - 'format' => 'text', - 'default' => $options['images']['small_width'], - 'allow_blank' => true, - 'validation' => 'positive_integer', - 'affectsStyle' => true, - 'title' => I18n::_('Small image width'), - 'description' => I18n::_('Maximum size of a small image (visible if \'Small images\' are switched on in the gallery editor). These settings don\'t affect original image.'), - ], - 'imagesSmallHeight' => [ - 'format' => 'text', - 'default' => $options['images']['small_height'], - 'allow_blank' => true, - 'validation' => 'positive_integer', - 'affectsStyle' => true, - 'title' => I18n::_('Small image height'), - 'description' => '', - ], - 'imagesMediumWidth' => [ - 'format' => 'text', - 'default' => $options['images']['medium_width'], - 'allow_blank' => true, - 'validation' => 'positive_integer', - 'affectsStyle' => true, - 'title' => I18n::_('Medium image width'), - 'description' => I18n::_('Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.'), - ], - 'imagesMediumHeight' => [ - 'format' => 'text', - 'default' => $options['images']['medium_height'], - 'validation' => 'positive_integer', - 'allow_blank' => true, - 'affectsStyle' => true, - 'title' => I18n::_('Medium image height'), - 'description' => '', - ], - 'imagesLargeWidth' => [ - 'format' => 'text', - 'default' => $options['images']['large_width'], - 'allow_blank' => true, - 'css_units' => false, - 'validation' => 'positive_integer', - 'title' => I18n::_('Large image width'), - 'description' => I18n::_('Maximum size of a large image (visible if \'Large images\' are switched on in the gallery editor). These settings don\'t affect original image.'), - ], - 'imagesLargeHeight' => [ - 'format' => 'text', - 'default' => $options['images']['large_height'], - 'allow_blank' => true, - 'css_units' => false, - 'affectsStyle' => true, - 'title' => I18n::_('Large image height'), - 'description' => '', - ], - ], - - 'socialMediaLinks' => [ - '_' => ['title' => I18n::_('Social media buttons')], - 'links' => [ - 'children' => [ - 'icon' => [ - 'format' => 'icon-readonly', - 'default' => 'link', - 'title' => I18n::_('Icon'), - 'placeholder' => I18n::_('Icon'), - 'description' => '', - ], - 'url' => [ - 'format' => 'url', - 'default' => '', - 'title' => I18n::_('Link'), - 'placeholder' => ('https://example.com'), - 'description' => '', - ], - ], - 'default' => [], - 'title' => I18n::_('Social media links'), - 'description' => '', - ], - 'location' => [ - 'format' => 'select', - 'allow_blank' => false, - 'default' => 'footer', - 'values' => [ - 'footer', - 'additionalText', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Location'), - 'description' => I18n::_('Location of social media buttons. Content of additional text or additional footer will be replaced.'), - ], - ], - - 'socialMediaButtons' => [ - '_' => [ - 'title' => I18n::_('Custom social media buttons'), - ], - 'socialMediaHTML' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('HTML code'), - 'description' => I18n::_('Paste or write your HTML code here.'), - 'requires_feature' => 'custom_javascript', - ], - 'socialMediaJS' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Javascript code'), - 'description' => I18n::_('Paste or write your Javascript code here.'), - 'requires_feature' => 'custom_javascript', - ], - 'socialMediaLocation' => [ - 'format' => 'select', - 'allow_blank' => false, - 'default' => 'footer', - 'values' => [ - 'footer', - 'additionalText', - ], - 'affectsStyle' => true, - 'title' => I18n::_('Location'), - 'description' => I18n::_('Location of social media buttons. Content of additional text or additional footer will be replaced.'), - ], - ], - - 'banners' => [ - '_' => [ - 'title' => I18n::_('Banners'), - ], - 'banner1_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (1)', - 'description' => '' . I18n::_('description_banner') . '', - ], - 'banner1_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (1)', - 'description' => I18n::_('description_banner_link'), - ], - 'banner2_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (2)', - 'description' => '', - ], - 'banner2_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (2)', - 'description' => '', - ], - 'banner3_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (3)', - 'description' => '', - ], - 'banner3_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (3)', - 'description' => '', - ], - 'banner4_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (4)', - 'description' => '', - ], - 'banner4_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (4)', - 'description' => '', - ], - 'banner5_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (5)', - 'description' => '', - ], - 'banner5_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (5)', - 'description' => '', - ], - 'banner6_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (6)', - 'description' => '', - ], - 'banner6_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (6)', - 'description' => '', - ], - 'banner7_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (7)', - 'description' => '', - ], - 'banner7_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (7)', - 'description' => '', - ], - 'banner8_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (8)', - 'description' => '', - ], - 'banner8_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (8)', - 'description' => '', - ], - 'banner9_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (9)', - 'description' => '', - ], - 'banner9_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (9)', - 'description' => '', - ], - 'banner10_image' => [ - 'format' => 'image', - 'default' => '', - 'min_width' => 1, - 'min_height' => 1, - 'max_width' => 3000, - 'max_height' => 3000, - 'title' => I18n::_('Banner image') . ' (10)', - 'description' => '', - ], - 'banner10_link' => [ - 'format' => 'url', - 'allow_blank' => true, - 'default' => null, - 'link' => true, - 'title' => I18n::_('Banner link') . ' (10)', - 'description' => '', - ], - ], - - 'language' => [ - '_' => [ - 'title' => I18n::_('Language'), - ], - 'language' => [ - 'format' => 'select', - 'allow_blank' => false, - 'default' => $options['default_language'], - 'values' => $options['languages'], - 'title' => I18n::_('Interface language'), - 'description' => I18n::_('description_language'), - ], - ], - - 'settings' => [ - '_' => [ - 'title' => I18n::_('Other settings'), - ], - 'googleAnalyticsId' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Google Analytics measurement ID'), - 'placeholder' => 'G-XXXXXXXXXX', - 'validator' => 'GoogleAnalytics', - 'description' => I18n::_('How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.'), - ], - 'googleTagManagerContainerId' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Google Tag Manager container ID'), - 'placeholder' => 'GTM-XXXXXX', - 'description' => I18n::_('How to get the container ID. Leave blank if Google Analytics measurement ID is filled.'), - ], - 'googleSiteVerification' => [ - 'format' => 'text', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Google site verification tag'), - 'description' => I18n::_('Google ownership verification tag. More info.'), - 'requires_feature' => 'custom_javascript', - ], - 'jsInclude' => [ - 'format' => 'longtext', - 'allow_blank' => true, - 'default' => '', - 'html_entities' => true, - 'title' => I18n::_('Javascript include'), - 'description' => I18n::_('javascript_include'), - 'requires_feature' => 'custom_javascript', - ], - ], -]; - -if (@file_exists($SITE_ROOT_PATH . '_plugin_shop/inc.settings.php')) { - include $SITE_ROOT_PATH . '_plugin_shop/inc.settings.php'; +// The shop's licence: the engine's templates check the global $shopEnabled +if (is_file($SITE_ROOT_PATH . '_plugin_shop/initializer.php')) { + require_once $SITE_ROOT_PATH . '_plugin_shop/initializer.php'; } -// special settings for hosted sites (nothing special) -if (@file_exists($ENGINE_ROOT_PATH . 'plan')) { - $hostingPlan = file_get_contents($ENGINE_ROOT_PATH . 'plan'); - - // settings for PRO and SHOP hosting plans - if ($hostingPlan > 1) { - $settingsDefinition['settings']['hideBertaCopyright'] = ['format' => 'select', 'default' => 'no', 'values' => ['yes', 'no'], 'title' => I18n::_('Hide copyrights'), 'description' => I18n::_('Hides Berta\'s copyrights')]; - } - $options['HOSTING_PLAN'] = $hostingPlan; -} else { - $options['HOSTING_PLAN'] = null; -} +$hostingPlan = UserModel::hostingPlan(); +$settingsDefinition = (require __DIR__ . '/inc.setting-definition.php')(bertaUntranslated(...), ! empty($shopEnabled), $hostingPlan); -// disable multisites for basic plan -if ((isset($hostingPlan) && $hostingPlan == 1) || (isset($options['HOSTING_PROFILE']) && ! $options['HOSTING_PROFILE'])) { +// The basic hosting plan has no multisites +if ($hostingPlan === 1 || (isset($options['HOSTING_PROFILE']) && ! $options['HOSTING_PROFILE'])) { $options['MULTISITES'] = []; $options['MULTISITE'] = ''; - $options['MULTISITE_DISABLED'] = true; -} else { - $options['MULTISITE_DISABLED'] = false; } diff --git a/engine/lang/en.php b/engine/lang/en.php index bc85ca9bd..bbc7220d9 100644 --- a/engine/lang/en.php +++ b/engine/lang/en.php @@ -3,29 +3,17 @@ return [ 'berta_copyright_text' => 'Built with Berta.me', - 'margins_help_text' => 'Margins are tricky. Use px or em as units. You can set margins in 4 ways, by entering:
              1 value - sets top, right, bottom and left margins to the same value. Example: 10px.
              2 values - sets top and bottom margins to the first value, left and right - to the second. Example: 0 5px.
              3 values - sets top margin to the first value, left and right - to the second, bottom - to the third value. Example: 10px 0 20px.
              4 values - sets all margins in the following order: top, right, bottom, left. Example: 10px 0 20px 2px.', - 'units_help_text' => 'Valid units for any numerical value are:
              px - pixels. Example: 10px. By using just numbers value WILL NOT change.
              em - 1em = one length of letter M in the font used. Example: 1em
              % - percent of the font size or percent of the dimensions of the container element (e.g. the page etc.)', - - 'sections_help_text' => 'Sections are main divisions in your site. Think of them as containers for your content. They appear as menu items in the main menu. ', - 'sites_help_text' => 'Multisites are fully functional and independent websites. You can use multiple sites to create your Berta website multilingual.', - 'external_link_help_text' => 'If you want any of the items in your main menu to lead the visitor somewhere else than your site, specify the external link. It can be an email link (e.g., mailto:sombeody@someplace.net) or a link to another website (e.g. http://www.example.com).', - - 'password_help_text' => 'Password must be at least 6 characters long
              and containing alphanumeric (A-Z, a-z, 0-9) characters.', - - - 'googleFont_description' => 'Type in any google font name. To check available fonts go to Google Fonts. Remember – google font overrides system font. Leave empty if you want to use system font. Example: Marvel, Marvel:700italic or Josefin Slab:700italic', - 'description_tagsMenu_x' => 'Submenu X position in pixels (i.e. 10px)', - 'description_tagsMenu_y' => 'Submenu Y position in pixels (i.e. 10px)', - 'description_menu_position' => 'Menu position', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'Banner link \'https://\' should be in front of the address', - 'description_language' => 'Choose language of interface.', - 'Heading position' => 'Heading position fixed or absolute. Fixed always stays in one place, absolute moves together with content.', - 'description_heading_position' => 'Heading position fixed or absolute. Fixed always stays in one place, absolute moves together with content.', - 'description_submenu_alwaysopen' => 'Submenu is open when menu item is current.', - 'javascript_include' => 'Javascript code included right before closing </body> element. Learn more how to include JavaScript code.', - 'description_custom_css' => 'Place your custom CSS code here. Any existing CSS definitions will be overwritten. For more information visit our WIKI.', - 'seo_help_text' => 'Search Engine Optimization (SEO) helps Google to find your website and improves its rank in search results. Learn more how it works.', + 'google_font_description' => 'Type in any google font name. To check available fonts go to Google Fonts. Remember – google font overrides system font. Leave empty if you want to use system font. Example: Marvel, Marvel:700italic or Josefin Slab:700italic', + 'tags_menu_x_description' => 'Submenu X position in pixels (i.e. 10px)', + 'tags_menu_y_description' => 'Submenu Y position in pixels (i.e. 10px)', + 'menu_position_description' => 'Menu position', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'Banner link \'https://\' should be in front of the address', + 'language_description' => 'Choose language of interface.', + 'heading_position_description' => 'Heading position fixed or absolute. Fixed always stays in one place, absolute moves together with content.', + 'tags_menu_always_open_description' => 'Submenu is open when menu item is current.', + 'js_include_description' => 'Javascript code included right before closing </body> element. Learn more how to include JavaScript code.', + 'custom_css_description' => 'Place your custom CSS code here. Any existing CSS definitions will be overwritten. For more information visit our WIKI.', 'section_type' => 'Type', 'section_type_tip' => 'Defines the layout and functionality of the section.', 'select_on' => 'On', @@ -33,4 +21,38 @@ 'shuffle_marked' => 'Shuffle marked entries and galleries', 'entries_limit' => 'Entries limit', 'entries_limit_tip' => 'How many entries to show in this section', + + // shop settings + 'shop_currency_description' => 'Currency abbreviation or symbol shown with every price on the site.', + 'shop_order_email_subject_description' => 'Subject of the order email sent to the customer.', + 'shop_seller_details_description' => 'Your business and bank account details, included in order emails. Customers paying by bank transfer pay to the account given here.', + 'shop_invoices_description' => 'Choose "yes" to attach a PDF invoice to the customer\'s order email and to download invoices in Orders. Invoices need the Seller details above, with your business and bank account details: without them no invoice is made.', + 'shop_basket_expires_minutes_description' => 'Minutes the products in a basket stay reserved after the customer\'s last action in the shop.', + 'shop_add_to_basket_description' => 'Text of the button that adds a product to the basket.', + 'shop_added_to_basket_description' => 'Message shown after a product is added to the basket.', + 'shop_out_of_stock_description' => 'Message shown when a product is sold out.', + 'shop_email_description' => 'Your email address, where the shop sends each new order.', + 'shop_payment_method_description' => 'Payment methods offered at checkout. PayPal and card payments also need the PayPal credentials below.', + 'shop_promo_code_description' => 'A code that gives customers a discount. When it and the discount below are filled in, the cart asks for a promo code.', + 'shop_promo_code_discount_description' => 'Discount in percent for customers who enter the promo code.', + 'shop_terms_link_description' => 'Address of your terms of service page. When filled in, the cart shows a link to it before checkout.', + 'shop_weight_unit_description' => 'Unit of product weights, such as g or oz. Shipping costs are worked out from the weights.', + 'shop_paypal_description' => 'To take PayPal and debit or credit card payments, connect your PayPal business account. Fill in one set of credentials below, not both: the REST app credentials (use these for a new setup) or the Classic API credentials (older shops already set up with them). When both are filled in, the REST ones are used.', + 'shop_paypal_currency_code_description' => 'Three-letter code of the currency your customers pay in, for example EUR or USD. Use the currency of your shop prices: when left empty, PayPal charges the same amounts in US dollars, without converting them. Currencies PayPal supports', + 'shop_paypal_sandbox_description' => 'When on, payments go to PayPal’s sandbox, where no real money moves, so you can try out the checkout. The sandbox needs sandbox credentials, which differ from your live ones. Turn it off and enter your live credentials before you start selling.', + 'shop_paypal_rest_description' => 'Where to find them: log in to the PayPal Developer Dashboard with your PayPal business account and open Apps & Credentials. Open your app there, or click Create App to make one, and copy its Client ID and Secret into the fields below. For test mode, copy them from the Sandbox tab instead of Live. PayPal’s guide', + 'shop_paypal_client_id_description' => 'A long code of letters and numbers, shown on your app’s page.', + 'shop_paypal_client_secret_description' => 'Shown below the Client ID on the same page, reveal it to copy it. Keep it private.', + 'shop_paypal_classic_description' => 'Only for shops already set up with them: leave these empty when you use the REST app credentials above. Where to find them: log in at paypal.com, go to Account Settings › Account access, click Update next to API access, then Manage API credentials under NVP/SOAP API integration (Classic). For test mode, do the same at sandbox.paypal.com with a sandbox business account. PayPal’s guide', + 'shop_paypal_user_name_description' => 'Looks like yourname_api1.example.com.', + 'shop_paypal_signature_description' => 'A long string of letters and numbers.', + 'shop_cart_image_description' => 'Image shown instead of the shopping cart link text. Recommended size: up to 50 × 50 pixels.', + 'shop_price_item_font_color_description' => 'Text color of the price shown on each product entry.', + 'shop_price_item_font_family_description' => 'Font of the product price. When a Google font is filled in below, it is used instead.', + 'shop_price_item_font_size_description' => 'Size of the product price, for example 12px or 1.2em.', + 'shop_price_item_font_weight_description' => 'Shows the product price in normal or bold text.', + 'shop_price_item_font_style_description' => 'Shows the product price in normal or italic text.', + 'shop_price_item_font_variant_description' => 'small-caps shows the letters in the price, such as the currency, as small capitals.', + 'shop_price_item_line_height_description' => 'Height of the product price\'s text line, for example 14px, 1.2em or normal.', + 'shop_entry_width_description' => 'Width of product entries in pixels. Used only by the Messy template when its layout is not responsive. An entry\'s own width overrides it. Leave empty for the default width.', ]; diff --git a/engine/lang/es.php b/engine/lang/es.php index fc16370c8..d0ff45576 100644 --- a/engine/lang/es.php +++ b/engine/lang/es.php @@ -3,27 +3,18 @@ return [ 'berta_copyright_text' => 'Construido con Berta.me', - 'margins_help_text' => 'Los márgenes son difíciles. Usa px o em como unidades. Puede establecer los márgenes de 4 maneras, by entering:
              1 value - Establece la parte superior, los márgenes derecho, inferior e izquierdo con el mismo valor. Ejemplo: 10px.
              2 valores - establece los márgenes superior e inferior al primer valor, izquierda y derecha - a la segunda. Ejemplo: 0 5px.
              3 values - establecer el margen superior al primer valor, izquierda y derecha - a la segunda, parte inferior - al tercer valor. Ejemplo: 10px 0 20px.
              4 valores - establece los márgenes en el siguiente orden: arriba, derecha, abajo, izquierda. Ejemplo: 10px 0 20px 2px.', - 'units_help_text' => 'Las unidades válidas para cualquier valor numérico son:
              px - pixels. Ejemplo: 10px. Mediante el uso de sólo los valores de los números, NO VA A CAMBIAR.
              em - 1em = un tramo de la letra M en la fuente utilizada. Ejemlo: 1em
              % - un porcentaje del tamaño de la fuente o un porcentaje de las dimensiones del elemento de contenedor (por ejemplo: la página, etc)', - - 'sections_help_text' => 'Las secciones son las divisiones principales en su sitio. Piense en ellos como contenedores para el contenido. Aparecen como elementos de menú en el menú principal. ', - 'external_link_help_text' => 'Si desea cualquiera de los artículos en su menú principal para dirigir al visitante a otro lugar que no sea su sitio, especificar el enlace externo. Puede ser un enlace de correo electrónico (por ejemplo: mailto:sombeody@someplace.net) o un enlace a otro sitio web (por ejemplo: http://www.example.com).', - - 'password_help_text' => 'La contraseña debe tener como mínimo 6 caracteres
              y que contiene alfanuméricos (A-Z, a-z, 0-9) caracteres.', - - - 'googleFont_description' => 'Escriba un nombre de fuente google fuente. Para comprobar las fuentes disponibles vaya a Google Fonts. Recuerde - fuente del sistema anula la google fuente. Dejar en blanco si desea utilizar la fuente del sistema. Ejemplo: Marvel, Marvel:700italic or Josefin Slab:700italic', - 'description_tagsMenu_x' => 'Submenu X posición en píxeles (es decir, 10px)', - 'description_tagsMenu_y' => 'Submenu Y posición en píxeles (es decir, 10px)', - 'description_menu_position' => 'Posición de menú', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'Enlace banner \'https://\' debe estar al frente de la dirección', - 'description_language' => 'Elija el idioma de la interfaz de Berta. Actualiza sitio, para aplicar.', - 'Heading position' => 'Fija o absoluta la posición de la cabecera. Fijo siempre se queda en un solo lugar, absoluta se mueve junto con el contenido.', - 'description_heading_position' => 'Fija o absoluta la posición de la cabecera. Fijo siempre se queda en un solo lugar, absoluta se mueve junto con el contenido.', - 'description_submenu_alwaysopen' => 'Submenú está abierto cuando elemento de menú está actualizada.', - 'javascript_include' => 'incluir código Javascript justo antes de cerrar el elemento </body>. Obtenga más información de cómo incluir código JavaScript.', - 'description_custom_css' => 'Coloca el código CSS personalizado aquí. Cualquier definiciones CSS existentes se sobrescribirán. Para obtener más información, visite nuestro WIKI.', + 'google_font_description' => 'Escriba el nombre de cualquier fuente de Google. Para ver las fuentes disponibles, vaya a Google Fonts. Recuerde: la fuente de Google anula la fuente del sistema. Déjelo vacío si desea usar una fuente del sistema. Ejemplo: Marvel, Marvel:700italic o Josefin Slab:700italic', + 'tags_menu_x_description' => 'Submenu X posición en píxeles (es decir, 10px)', + 'tags_menu_y_description' => 'Submenu Y posición en píxeles (es decir, 10px)', + 'menu_position_description' => 'Posición de menú', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'Enlace banner \'https://\' debe estar al frente de la dirección', + 'language_description' => 'Elija el idioma de la interfaz de Berta. Actualiza sitio, para aplicar.', + 'Heading position' => 'Posición de la cabecera', + 'heading_position_description' => 'Fija o absoluta la posición de la cabecera. Fijo siempre se queda en un solo lugar, absoluta se mueve junto con el contenido.', + 'tags_menu_always_open_description' => 'Submenú está abierto cuando elemento de menú está actualizada.', + 'js_include_description' => 'incluir código Javascript justo antes de cerrar el elemento </body>. Obtenga más información de cómo incluir código JavaScript.', + 'custom_css_description' => 'Coloca el código CSS personalizado aquí. Cualquier definiciones CSS existentes se sobrescribirán. Para obtener más información, visite nuestro WIKI.', 'section_type' => 'Type', 'section_type_tip' => 'Defines the layout and functionality of the section.', 'select_on' => 'On', @@ -31,4 +22,313 @@ 'shuffle_marked' => 'Shuffle marked entries and galleries', 'entries_limit' => 'Entries limit', 'entries_limit_tip' => 'How many entries to show in this section', + + // shop translations + 'Settings' => 'Ajustes', + 'Configuration values' => 'Valores de configuración', + 'Shop currency' => 'Moneda de la tienda', + 'shop_currency_description' => 'Abreviatura o símbolo de la moneda que se muestra con cada precio del sitio.', + 'Order email subject' => 'Asunto del correo del pedido', + 'shop_order_email_subject_description' => 'Asunto del correo del pedido que se envía al cliente.', + 'Seller details' => 'Datos del vendedor', + 'shop_seller_details_description' => 'Los datos de su empresa y de su cuenta bancaria, incluidos en los correos de pedido. Los clientes que pagan por transferencia bancaria pagan a la cuenta indicada aquí.', + 'Required.' => 'Obligatorio.', + 'PDF invoices' => 'Facturas en PDF', + 'shop_invoices_description' => 'Elija "Yes" para adjuntar una factura en PDF al correo del pedido del cliente y poder descargar las facturas en "Orders". Las facturas necesitan los datos del vendedor de arriba, con los datos de su empresa y de su cuenta bancaria: sin ellos no se crea ninguna factura.', + 'Basket expires' => 'Caducidad de la cesta', + 'shop_basket_expires_minutes_description' => 'Minutos que los productos de una cesta permanecen reservados tras la última acción del cliente en la tienda.', + 'Add to basket' => 'Añadir a la cesta', + 'shop_add_to_basket_description' => 'Texto del botón que añade un producto a la cesta.', + 'Added to basket' => 'Añadido a la cesta', + 'shop_added_to_basket_description' => 'Mensaje que se muestra después de añadir un producto a la cesta.', + 'Out of stock' => 'Agotado', + 'shop_out_of_stock_description' => 'Mensaje que se muestra cuando un producto está agotado.', + 'Email' => 'Correo electrónico', + 'shop_email_description' => 'Su dirección de correo electrónico, a la que la tienda envía cada pedido nuevo.', + 'PayPal and debit or credit card' => 'PayPal y tarjeta de débito o crédito', + 'Bank transfer' => 'Transferencia bancaria', + 'PayPal, debit or credit card and bank transfer' => 'PayPal, tarjeta de débito o crédito y transferencia bancaria', + 'Payment method' => 'Método de pago', + 'shop_payment_method_description' => 'Métodos de pago que se ofrecen al finalizar la compra. Los pagos con PayPal y con tarjeta también necesitan las credenciales de PayPal de abajo.', + 'Promo code' => 'Código promocional', + 'shop_promo_code_description' => 'Un código que da un descuento a los clientes. Si se rellenan este campo y el descuento de abajo, el carrito pide un código promocional.', + 'Promo code discount' => 'Descuento del código promocional', + 'shop_promo_code_discount_description' => 'Descuento en porcentaje para los clientes que introducen el código promocional.', + 'Terms of service link' => 'Enlace a las condiciones del servicio', + 'shop_terms_link_description' => 'Dirección de su página de condiciones del servicio. Si se rellena, el carrito muestra un enlace a ella antes de finalizar la compra.', + 'Weight units' => 'Unidad de peso', + 'shop_weight_unit_description' => 'Unidad del peso de los productos, como g u oz. Los gastos de envío se calculan a partir de los pesos.', + 'PayPal' => 'PayPal', + 'shop_paypal_description' => 'Para aceptar pagos con PayPal y con tarjeta de débito o crédito, conecte su cuenta empresarial de PayPal. Rellene un solo conjunto de credenciales de abajo, no ambos: las credenciales de la app REST (úselas para una configuración nueva) o las credenciales de la API Classic (tiendas antiguas ya configuradas con ellas). Si se rellenan ambos, se usan las REST.', + 'Currency' => 'Moneda', + 'Required for PayPal payments.' => 'Obligatorio para los pagos con PayPal.', + 'shop_paypal_currency_code_description' => 'Código de tres letras de la moneda en la que pagan sus clientes, por ejemplo EUR o USD. Use la moneda de los precios de su tienda: si se deja vacío, PayPal cobra los mismos importes en dólares estadounidenses, sin convertirlos. Monedas que admite PayPal', + 'Test mode (sandbox)' => 'Modo de prueba (sandbox)', + 'shop_paypal_sandbox_description' => 'Cuando está activado, los pagos van al entorno de pruebas de PayPal (sandbox), donde no se mueve dinero real, para que pueda probar el proceso de compra. El entorno de pruebas necesita credenciales de sandbox, distintas de las reales. Desactívelo e introduzca sus credenciales reales antes de empezar a vender.', + 'PayPal REST app credentials (recommended)' => 'Credenciales de la app REST de PayPal (recomendado)', + 'shop_paypal_rest_description' => 'Dónde encontrarlas: inicie sesión en el PayPal Developer Dashboard con su cuenta empresarial de PayPal y abra Apps & Credentials. Abra allí su app, o haga clic en Create App para crear una, y copie su Client ID y su Secret en los campos de abajo. Para el modo de prueba, cópielos desde la pestaña Sandbox en lugar de Live. Guía de PayPal', + 'Client ID' => 'Client ID', + 'shop_paypal_client_id_description' => 'Un código largo de letras y números, que aparece en la página de su app.', + 'Secret' => 'Secret', + 'shop_paypal_client_secret_description' => 'Aparece debajo del Client ID en la misma página; muéstrelo para copiarlo. Manténgalo en privado.', + 'PayPal Classic API credentials (older shops)' => 'Credenciales de la API Classic de PayPal (tiendas antiguas)', + 'shop_paypal_classic_description' => 'Solo para tiendas ya configuradas con ellas: deje estos campos vacíos si usa las credenciales de la app REST de arriba. Dónde encontrarlas: inicie sesión en paypal.com, vaya a Account Settings › Account access, haga clic en Update junto a API access y luego en Manage API credentials en NVP/SOAP API integration (Classic). Para el modo de prueba, haga lo mismo en sandbox.paypal.com con una cuenta empresarial de sandbox. Guía de PayPal', + 'API username' => 'Usuario de la API', + 'shop_paypal_user_name_description' => 'Tiene este aspecto: yourname_api1.example.com.', + 'API password' => 'Contraseña de la API', + 'API signature' => 'Firma de la API', + 'shop_paypal_signature_description' => 'Una cadena larga de letras y números.', + 'Design' => 'Diseño', + 'Shopping cart image' => 'Imagen del carrito de compra', + 'shop_cart_image_description' => 'Imagen que se muestra en lugar del texto del enlace del carrito de compra. Tamaño recomendado: hasta 50 × 50 píxeles.', + 'Color' => 'Color', + 'shop_price_item_font_color_description' => 'Color del texto del precio que se muestra en cada entrada de producto.', + 'Font face' => 'Fuente', + 'shop_price_item_font_family_description' => 'Fuente del precio del producto. Si se indica una fuente de Google abajo, se usa esa.', + 'Google web fonts' => 'Fuentes web de Google', + 'Font size' => 'Tamaño de fuente', + 'shop_price_item_font_size_description' => 'Tamaño del precio del producto, por ejemplo 12px o 1.2em.', + 'Font weight' => 'Grosor de fuente', + 'shop_price_item_font_weight_description' => 'Muestra el precio del producto en texto normal o en negrita.', + 'Font style' => 'Estilo de fuente', + 'shop_price_item_font_style_description' => 'Muestra el precio del producto en texto normal o en cursiva.', + 'Font variant' => 'Variante de fuente', + 'shop_price_item_font_variant_description' => 'small-caps muestra las letras del precio, como la moneda, en versalitas.', + 'Line height' => 'Altura de línea', + 'shop_price_item_line_height_description' => 'Altura de la línea de texto del precio del producto, por ejemplo 14px, 1.2em o normal.', + 'Shop entry width' => 'Ancho de entrada de la tienda', + 'shop_entry_width_description' => 'Ancho de las entradas de productos en píxeles. Solo lo usa la plantilla Messy cuando su diseño no es adaptable. El ancho propio de una entrada lo anula. Déjelo vacío para usar el ancho predeterminado.', + + // Settings and templates + ' description' => 'Descripción ', + 'Additional text' => 'Texto adicional', + 'Always auto-select a submenu item?' => '¿Seleccionar siempre un elemento del submenú?', + 'Background' => 'Fondo', + 'Background alignment' => 'Alineación del fondo', + 'Background button type' => 'Tipo de botones del fondo', + 'Background color' => 'Color de fondo', + 'Background image' => 'Imagen de fondo', + 'Background position' => 'Posición del fondo', + 'Background tiling' => 'Repetición del fondo', + 'Background video (embed)' => 'Vídeo de fondo (incrustado)', + 'Background video ratio' => 'Proporción del vídeo de fondo', + 'Banner image' => 'Imagen del banner', + 'Banner link' => 'Enlace del banner', + 'Banners' => 'Banners', + 'Button color' => 'Color de los botones', + 'Caption alignment' => 'Alineación del pie de foto', + 'Centered content width' => 'Ancho del contenido centrado', + 'Centered contents' => 'Contenido centrado', + 'Centered layout' => 'Diseño centrado', + 'Centering guides color tone' => 'Tono de las guías de centrado', + 'Choose "no" to hide page heading in first section.' => 'Elija "No" para ocultar el encabezado de página en la primera sección.', + 'Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.' => 'Elija "No" para ocultar la primera sección en el menú principal. El enlace del título de la página (o de la imagen de cabecera) llevará a ella. NOTA: este ajuste no tiene efecto si la sección tiene un submenú; entonces siempre está visible.', + 'Choose "no" to hide the menu in first section.' => 'Elija "No" para ocultar el menú en la primera sección.', + 'Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.' => 'Elija "Yes" para seleccionar automáticamente el primer elemento del submenú al hacer clic en un elemento del menú. Solo funciona si hay un submenú.', + 'Choose "yes" to show gridlines in background. Available only in Messy template.' => 'Elija "Yes" para mostrar líneas de cuadrícula en el fondo. Solo disponible en la plantilla Messy.', + 'Choose theme' => 'Elija un tema', + 'Color of the element of the currently opened section' => 'Color del elemento de la sección abierta', + 'Color of the element under mouse cursor' => 'Color del elemento bajo el cursor', + 'Color of the fullscreen gallery background.' => 'Color de fondo de la galería a pantalla completa.', + 'Color tone for centering guides (dark for bright background colors, bright for dark background colors).' => 'Tono de las guías de centrado (oscuro para colores de fondo claros, claro para colores de fondo oscuros).', + 'Color when hovered' => 'Color al pasar el cursor', + 'Color when opened' => 'Color al estar abierto', + 'Color when selected' => 'Color al estar seleccionado', + 'Column count for Mash-up section.' => 'Número de columnas de la sección Mash-up.', + 'Columns' => 'Columnas', + 'Content position' => 'Posición del contenido', + 'Content width if layout is centered.' => 'Ancho del contenido cuando el diseño está centrado.', + 'Custom CSS' => 'CSS personalizado', + 'Custom social media buttons' => 'Botones de redes sociales personalizados', + 'Decoration' => 'Decoración', + 'Decoration when hovered' => 'Decoración al pasar el cursor', + 'Decoration when opened' => 'Decoración al estar abierto', + 'Decoration when selected' => 'Decoración al estar seleccionado', + 'Default gallery type' => 'Tipo de galería predeterminado', + 'Displayed image will be half of the original size, full size will be used for hi-res displays.' => 'La imagen se muestra a la mitad de su tamaño original; en pantallas de alta resolución se usa el tamaño completo.', + 'Distance between the gallery and the content below' => 'Distancia entre la galería y el contenido inferior', + 'Distance from entry to entry. In pixels.' => 'Distancia entre entradas, en píxeles.', + 'Distance in pixels for snap-to-grid dragging.' => 'Distancia en píxeles para ajustar a la cuadrícula al arrastrar.', + 'Empty space below gallery' => 'Espacio vacío bajo la galería', + 'Empty space on bottom' => 'Espacio vacío inferior', + 'Empty space on top' => 'Espacio vacío superior', + 'Enable navigation to page top' => 'Activar la navegación al principio de la página', + 'Enabled' => 'Activado', + 'Enables fullscreen gallery slide numbers.' => 'Activa los números de diapositiva de la galería a pantalla completa.', + 'Enables gallery fullscreen mode for new entries.' => 'Activa el modo de pantalla completa de la galería para las entradas nuevas.', + 'Enables responsive layout for mobile devices.' => 'Activa el diseño adaptable para dispositivos móviles.', + 'Entries are organised in columns. Column count can be changed under section settings.' => 'Las entradas se organizan en columnas. El número de columnas se cambia en los ajustes de la sección.', + 'Entry footer' => 'Pie de la entrada', + 'Entry heading' => 'Encabezado de la entrada', + 'Entry layout' => 'Diseño de la entrada', + 'Entry margins' => 'Márgenes de la entrada', + 'Entry max width' => 'Ancho máximo de la entrada', + 'Entry padding' => 'Relleno de la entrada', + 'Entry text max width' => 'Ancho máximo del texto de la entrada', + 'Favicon' => 'Favicon', + 'Fill window' => 'Llenar la ventana', + 'First page' => 'Primera página', + 'Gallery fullscreen settings:' => 'Ajustes de la galería a pantalla completa:', + 'Gallery margins' => 'Márgenes de la galería', + 'Gallery position' => 'Posición de la galería', + 'General font settings' => 'Ajustes generales de fuente', + 'Google Analytics measurement ID' => 'ID de medición de Google Analytics', + 'Google Tag Manager container ID' => 'ID de contenedor de Google Tag Manager', + 'Google ownership verification tag. More info.' => 'Etiqueta de verificación de propiedad de Google. Más información.', + 'Google site verification tag' => 'Etiqueta de verificación del sitio de Google', + 'Grid layout' => 'Diseño en cuadrícula', + 'Grid step' => 'Paso de la cuadrícula', + 'Gridlines color' => 'Color de las líneas de cuadrícula', + 'HTML code' => 'Código HTML', + 'Header image' => 'Imagen de cabecera', + 'Heading font' => 'Fuente del encabezado', + 'Heading font size' => 'Tamaño de fuente del encabezado', + 'Heading font style' => 'Estilo de fuente del encabezado', + 'Heading font variant' => 'Variante de fuente del encabezado', + 'Heading font weight' => 'Grosor de fuente del encabezado', + 'Heading line height' => 'Altura de línea del encabezado', + 'Heading margin' => 'Margen del encabezado', + 'Heading text color' => 'Color del texto del encabezado', + 'Height of text line. Use em, px or % values or the default value "normal"' => 'Altura de la línea de texto. Use valores em, px o %, o el valor predeterminado "normal"', + 'Hide copyrights' => 'Ocultar el copyright', + 'Hides Berta\'s copyrights' => 'Oculta el aviso de copyright de Berta', + 'Horizontal/vertical space between images when gallery is in "row"/"column" mode' => 'Espacio horizontal/vertical entre las imágenes cuando la galería está en modo "row"/"column"', + 'How big is the distance from the top menu to the other page elements' => 'Distancia entre el menú superior y los demás elementos de la página', + 'How far content is from menu?' => '¿A qué distancia está el contenido del menú?', + 'How far content is from page top?' => '¿A qué distancia está el contenido de la parte superior de la página?', + 'How far content is from sidebar?' => '¿A qué distancia está el contenido de la barra lateral?', + 'How far the content is from browser edges.' => 'Distancia entre el contenido y los bordes del navegador.', + 'How far the entry heading is form other elements in page.' => 'Distancia entre el encabezado de la entrada y los demás elementos de la página.', + 'How far the header is from the top of the screen?' => '¿A qué distancia está la cabecera de la parte superior de la pantalla?', + 'How far the heading is from other elements in page.' => 'Distancia entre el encabezado y los demás elementos de la página.', + 'How far the menu is form other elements in page.' => 'Distancia entre el menú y los demás elementos de la página.', + 'How far the menu is from the header text or header image.' => 'Distancia entre el menú y el texto o la imagen de cabecera.', + 'How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.' => 'Distancia entre la barra lateral y el borde izquierdo de la pantalla. Se ignora si el diseño centrado está activado.', + 'How the background fills the screen?' => '¿Cómo llena el fondo la pantalla?', + 'How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.' => 'Cómo obtener el ID de medición. Déjelo vacío si el ID de contenedor de Google Tag Manager está rellenado.', + 'How to get the container ID. Leave blank if Google Analytics measurement ID is filled.' => 'Cómo obtener el ID de contenedor. Déjelo vacío si el ID de medición de Google Analytics está rellenado.', + 'Hyperlinks' => 'Enlaces', + 'IMPORTANT! These settings will be overwritten, if you are using background gallery feature. You access it by clicking "edit background gallery" button in each section.' => '¡IMPORTANTE! Estos ajustes se sobrescriben si usa la galería de fondo. Se accede a ella con el botón "edit background gallery" de cada sección.', + 'IMPORTANT! This must be set as percentage. i.e. 60%' => '¡IMPORTANTE! Debe indicarse en porcentaje, p. ej. 60%', + 'Icon' => 'Icono', + 'Image gallery appearance:' => 'Aspecto de la galería de imágenes:', + 'Image numbers' => 'Números de imagen', + 'Image size ratio' => 'Proporción del tamaño de las imágenes', + 'Images have shadows?' => '¿Las imágenes tienen sombra?', + 'Images in the first page layout will be resized by this ratio. Think of it as percentage, e.g., 0.7 = 70% of the original image size.' => 'Las imágenes del diseño de la primera página se redimensionan con esta proporción. Piense en ella como un porcentaje, p. ej., 0.7 = 70% del tamaño original de la imagen.', + 'Images wiggle on mouse-over?' => '¿Las imágenes se mueven al pasar el cursor?', + 'Interface language' => 'Idioma de la interfaz', + 'Is background image enabled?' => '¿Está activada la imagen de fondo?', + 'Is enabled by default' => 'Activado por defecto', + 'Is first section visible in menu?' => '¿Es visible la primera sección en el menú?', + 'Is transparent?' => '¿Es transparente?', + 'Javascript code' => 'Código Javascript', + 'Javascript include' => 'Javascript incluido', + 'Keep ratio' => 'Mantener la proporción', + 'Language' => 'Idioma', + 'Large image height' => 'Alto de las imágenes grandes', + 'Large image width' => 'Ancho de las imágenes grandes', + 'Left margin' => 'Margen izquierdo', + 'Link' => 'Enlace', + 'Link address' => 'Dirección del enlace', + 'Link color' => 'Color de los enlaces', + 'Link color when clicked' => 'Color de los enlaces al hacer clic', + 'Link color when hovered' => 'Color de los enlaces al pasar el cursor', + 'Link decoration' => 'Decoración de los enlaces', + 'Link decoration when clicked' => 'Decoración de los enlaces al hacer clic', + 'Link decoration when hovered' => 'Decoración de los enlaces al pasar el cursor', + 'Location' => 'Ubicación', + 'Location of social media buttons. Content of additional text or additional footer will be replaced.' => 'Ubicación de los botones de redes sociales. Sustituyen el contenido del texto adicional o del pie de página adicional.', + 'Logo image' => 'Imagen del logotipo', + 'Main menu' => 'Menú principal', + 'Margin around gallery block' => 'Margen alrededor del bloque de la galería', + 'Margin around menu.' => 'Margen alrededor del menú.', + 'Margin around page heading or logo.' => 'Margen alrededor del encabezado de página o del logotipo.', + 'Margins' => 'Márgenes', + 'Margins around entries.' => 'Márgenes alrededor de las entradas.', + 'Mash-up columns' => 'Columnas Mash-up', + 'Maximum size of a large image (visible if \'Large images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Tamaño máximo de una imagen grande (visible si \'Large images\' está activado en el editor de galería). Estos ajustes no afectan a la imagen original.', + 'Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Tamaño máximo de una imagen mediana (visible si \'Medium images\' está activado en el editor de galería). Estos ajustes no afectan a la imagen original.', + 'Maximum size of a small image (visible if \'Small images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Tamaño máximo de una imagen pequeña (visible si \'Small images\' está activado en el editor de galería). Estos ajustes no afectan a la imagen original.', + 'Media' => 'Medios', + 'Medium image height' => 'Alto de las imágenes medianas', + 'Medium image width' => 'Ancho de las imágenes medianas', + 'Menu items separator' => 'Separador de los elementos del menú', + 'Menu margin' => 'Márgenes del menú', + 'Menu position' => 'Posición del menú', + 'Name of your site. Visible in all sections.' => 'Nombre de su sitio. Visible en todas las secciones.', + 'Navigation' => 'Navegación', + 'Opens in' => 'Se abre en', + 'Other settings' => 'Otros ajustes', + 'Page heading' => 'Encabezado de página', + 'Page heading, menu items and section entries is horizontally centered to window.' => 'El encabezado de página, los elementos del menú y las entradas de la sección están centrados horizontalmente en la ventana.', + 'Page layout' => 'Diseño de la página', + 'Page margins' => 'Márgenes de la página', + 'Page title (title bar)' => 'Título de la página (barra de título)', + 'Paste or write your HTML code here.' => 'Pegue o escriba aquí su código HTML.', + 'Paste or write your Javascript code here.' => 'Pegue o escriba aquí su código Javascript.', + 'Picture to use for page background.' => 'Imagen para el fondo de la página.', + 'Positioning of the image caption text.' => 'Posición del texto del pie de foto.', + 'Positon X' => 'Posición X', + 'Positon Y' => 'Posición Y', + 'Reset design' => 'Restablecer el diseño', + 'Reset design to default values.' => 'Restablece el diseño a los valores predeterminados.', + 'Responsive design' => 'Diseño adaptable', + 'Responsive layout' => 'Maquetación adaptable', + 'Select type for background gallery buttons.' => 'Elija el tipo de botones de la galería de fondo.', + 'Set the default state of image number visibility in slideshow galleries.' => 'Define si los números de imagen se muestran por defecto en las galerías de presentación.', + 'Sets how background behaves in relation with the browser window.' => 'Define cómo se comporta el fondo respecto a la ventana del navegador.', + 'Sets whether layout should be centered or not.' => 'Define si el diseño está centrado o no.', + 'Sets whether layout should be responsive or not.' => 'Define si el diseño es adaptable o no.', + 'Short site description. It should not be longer than one or two sentences.' => 'Descripción breve del sitio. No debe tener más de una o dos frases.', + 'Show gridlines' => 'Mostrar líneas de cuadrícula', + 'Show menu in first section?' => '¿Mostrar el menú en la primera sección?', + 'Show page heading in first section?' => '¿Mostrar el encabezado de página en la primera sección?', + 'Show slideshow image numbers' => 'Mostrar los números de imagen de la presentación', + 'Shows navigation button at the bottom when page is scrolled.' => 'Muestra un botón de navegación abajo cuando se desplaza la página.', + 'Sidebar' => 'Barra lateral', + 'Site heading' => 'Encabezado del sitio', + 'Slideshow loop mode' => 'Presentación en bucle', + 'Slideshow means that an image menu plus only one image is visible at a time. Row means that all images are visible.' => 'En "slideshow" se ve un menú de imágenes y solo una imagen a la vez. En "row" se ven todas las imágenes.', + 'Small image height' => 'Alto de las imágenes pequeñas', + 'Small image width' => 'Ancho de las imágenes pequeñas', + 'Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.' => 'Imagen pequeña que se muestra en la barra de direcciones del navegador. El archivo debe estar en formato PNG y medir 180x180 píxeles.', + 'Social media buttons' => 'Botones de redes sociales', + 'Social media links' => 'Enlaces a redes sociales', + 'Space between entries' => 'Espacio entre entradas', + 'Space between header and menu' => 'Espacio entre la cabecera y el menú', + 'Space between images and image navigation' => 'Espacio entre las imágenes y la navegación de imágenes', + 'Space between images in row and column' => 'Espacio entre las imágenes en fila y en columna', + 'Space width around separator' => 'Espacio alrededor del separador', + 'Submenu' => 'Submenú', + 'Submenu is allways open' => 'El submenú está siempre abierto', + 'Submenu is hidden' => 'El submenú está oculto', + 'Template' => 'Plantilla', + 'Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.' => 'Las plantillas son como pieles o temas para su sitio. Puede elegir una de las plantillas instaladas en su carpeta de plantillas. Para añadir una plantilla nueva a esta lista, súbala a la carpeta de plantillas por FTP.', + 'Text alignment' => 'Alineación del texto', + 'Text that appears in the bowser title bar' => 'Texto que aparece en la barra de título del navegador', + 'Texts' => 'Textos', + 'Texts in the website' => 'Textos del sitio web', + 'The distance from the separator to the menu item on both sides' => 'Distancia del separador a cada elemento del menú, a ambos lados', + 'The horizontal distance between the menu and the content area.' => 'Distancia horizontal entre el menú y el área de contenido.', + 'The vertical distance between the top of the page and the content area.' => 'Distancia vertical entre la parte superior de la página y el área de contenido.', + 'Theme' => 'Tema', + 'Themes are ready made sites with predefined design settings.' => 'Los temas son sitios listos para usar con ajustes de diseño predefinidos.', + 'Thumbnail container width' => 'Ancho del contenedor de miniaturas', + 'Thumbnails' => 'Miniaturas', + 'Thumbnails can be turned on by setting the section type to "Thumbnails enabled" & adding more than 1 images to background gallery.' => 'Las miniaturas se activan eligiendo el tipo de sección "Thumbnails enabled" y añadiendo más de 1 imagen a la galería de fondo.', + 'Top menu margins' => 'Márgenes del menú superior', + 'Top padding' => 'Relleno superior', + 'Vertical space between images and image navigation (the digits below the image) when gallery is in "slideshow" mode' => 'Espacio vertical entre las imágenes y la navegación de imágenes (los números bajo la imagen) cuando la galería está en modo "slideshow"', + 'Visited link color' => 'Color de los enlaces visitados', + 'Visited link decoration' => 'Decoración de los enlaces visitados', + 'Where the background image is positioned?' => '¿Dónde se sitúa la imagen de fondo?', + 'Width' => 'Ancho', + 'Width of content area' => 'Ancho del área de contenido', + 'Width of texts in the entries. This does not apply to the width of images.' => 'Ancho de los textos de las entradas. No afecta al ancho de las imágenes.', + 'Width of the left column' => 'Ancho de la columna izquierda', + 'Your name' => 'Su nombre', + 'Your name will be put in a meta-tag in the code of your site. You can choose any name ;)' => 'Su nombre se incluirá en una metaetiqueta del código de su sitio. Puede elegir cualquier nombre ;)', + 'create new entry' => 'crear una entrada nueva', + 'create new entry here' => 'crear una entrada nueva aquí', ]; diff --git a/engine/lang/fr.php b/engine/lang/fr.php index f7f50a7bc..dc0685490 100644 --- a/engine/lang/fr.php +++ b/engine/lang/fr.php @@ -6,57 +6,23 @@ // Translations from the top "admin" menu /* menu item */ - 'close this' => 'fermer', /* menu item */ - 'my site' => 'mon site', /* menu item */ - 'sections' => 'sections', /* menu item */ - 'settings' => 'paramètres', /* menu item */ - 'design' => 'design', /* menu item */ - 'profile' => 'profil', /* menu item */ - 'log out' => 'se déconnecter', - - // Translatins from login / logout window - - 'Logout ok. Please wait...' => 'Déconnexion. Merci de patienter…', - 'Log in' => 'Se connecter', // Translatins from sections editor (sections.php) /* title */ - 'Sections' => 'Sections', /* column */ - 'Title as displayed in main menu' => 'Titre affiché dans le menu principal', /* column */ - 'Type' => 'Type', /* column */ - 'Details' => 'Détails', /* column */ - 'Is published?' => 'Publié ?', /* column */ - 'Delete' => 'Supprimer', /* button */ - 'create new section' => 'créer une nouvelle section', /* button */ - 'delete' => 'supprimer', - - 'What are sections?' => 'Que sont les sections ?', - 'sections_help_text' => 'Les sections sont les principales divisions de votre site. Elles servent à contenir et organiser votre contenu. Chaque section est accessible dans le menu principal. ', - 'What is the "external link"?' => 'Qu’est-ce qu’un « lien externe » ?', - 'external_link_help_text' => 'Si vous désirez qu\'un élément de votre menu principal pointe ailleurs que sur une section de votre site, utilisez le lien externe. Il peut être un lien de type email (ex., mailto:exemple@site.net) ou un lien vers un autre site web (ex. http://www.example.com).', - - // Translations from profile page (profile.php) - 'Profile' => 'Profil', - 'Old password' => 'Ancien mot de passe', - 'New password' => 'Nouveau mot de passe', - 'Retype new password' => 'Confirmez le mot de passe', - 'Change password' => 'Changer le mot de passe', - 'password_help_text' => 'Le mot de passe doit comporter au moins 6 caractères alphanumériques (A-Z, a-z, 0-9).', - '' => '', // Translations from settings page (settings.php) @@ -64,13 +30,8 @@ 'Settings' => 'Paramètres', /* title */ 'Design' => 'Design', - 'These are settings for template' => 'Voici les paramètres des templates', - 'Some help with CSS values' => 'Un peu d’aide avec les propriétés CSS', - 'Units' => 'Unités', - 'units_help_text' => 'Les unités valides pour les valeurs numériques sont :
              px - pixels
              em - 1 em = une longueur de lettre M dans la fonte utilisée
              % - pourcentage en fonction de la taille de la fonte ou de la dimension de l\'élément conteneur (ex. la page).', 'Margins' => 'Marges', - 'margins_help_text' => 'Se servir des marges est un peu délicat. Utilisez les unités px ou em. Vous pouvez déclarer des marges de 4 façons, en écrivant :
              1 valeur - déclarez la marge du haut, droite, bas et gauche en une seule valeur. Exemple : 10px.
              2 valeurs - déclarez la marge de haut et bas avec la première valeur, et gauche et droite avec la seconde valeur. Exemple : 0 5px.
              3 valeurs - déclarez la marge de haut avec la première valeur, de gauche et droite avec la seconde valeur et la marge de bas avec la troisième valeur. Exemple : 10px 0 20px.
              4 valeurs - déclarez toutes les marges selon l\'ordre suivant: haut, droit, bas, gauche. Exemple : 10px 0 20px 2px.', // Translations from inc.settings.php @@ -83,7 +44,6 @@ 'Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.' => 'Les templates définissent l’apparence de votre site. Vous pouvez choisir un jeu de templates parmi ceux installés dans votre dossier de templates. Pour ajouter un nouveau jeu à cette liste, téléchargez-le dans ce dossier via FTP.', /* setting tab */ - 'Info' => 'Info', /* setting name */ 'Your name' => 'Votre nom', /* help text */ @@ -95,7 +55,7 @@ /* setting name */ ' description' => ' description', /* help text */ - 'Site description. It should not be longer than one or two sentences.' => 'Description du site. Pas plus d’une ou deux phrases.', + 'Short site description. It should not be longer than one or two sentences.' => 'Description du site. Pas plus d’une ou deux phrases.', /* setting tab */ 'Navigation' => 'Navigation', @@ -149,9 +109,7 @@ /* setting subcategory */ 'Image gallery appearance:' => 'Apparence de la galerie d’image:', /* setting name */ - 'Video player' => 'Lecteur vidéo', /* help text */ - 'Choose between the two visually different players for your video files.' => 'Choisissez parmie les deux lecteurs pour la lecture de vos vidéos.', /* setting tab */ 'Banners' => 'Bannières', @@ -172,7 +130,6 @@ // Translations from entries view (and editing) - '

              Congratulations! You have successfully installed Berta.

              Now, before adding your content, you have to create a new section. Go to the sections page and do that!

              ' => '

              Bravo ! Vous avez bien installé Berta.

              Maintenant, avant d’ajouter du contenu, vous devez créer une première section. Allez à la page sections et faites-le !

              ', 'create new entry here' => 'ajoutez une entrée ici', 'create new entry' => 'ajoutez une entrée', @@ -266,7 +223,6 @@ /* setting name */ 'Header image' => 'Image d’entête', /* help text */ - 'Picture to use instead of text.' => 'Image à utiliser au lieu du texte', /* help text */ 'How far the heading is from other elements in page.' => 'Distance entre l’entête et les autres éléments de la page.', /* help text */ @@ -342,7 +298,6 @@ /* setting name */ 'Left margin' => 'Marge gauche', /* help text */ - 'How far the sidebar is from the left side of the screen.' => 'Distance entre le menu et la gauche de l’écran.', /* setting name */ 'Top padding' => 'Marge supérieure', /* help text */ @@ -431,25 +386,25 @@ /* help text */ 'IMPORTANT! This must be set as percentage. i.e. 60%' => 'Important : à spécifier en pourcentages, ex. : 60%', - 'googleFont_description' => 'Tapez le nom d’une fonte Google font. Pour voir les fontes disponibles, rendez-vous sur Google Fonts. Attention – les fontes Google font prennent le dessus sur les fontes système. Laissez le champ vide pour utiliser une fonte système. Exemple : Marvel, Marvel:700italic ou Josefin Slab:700italic', + 'google_font_description' => 'Tapez le nom d’une fonte Google font. Pour voir les fontes disponibles, rendez-vous sur Google Fonts. Attention – les fontes Google font prennent le dessus sur les fontes système. Laissez le champ vide pour utiliser une fonte système. Exemple : Marvel, Marvel:700italic ou Josefin Slab:700italic', 'Menu position' => 'Position du menu', 'Positon X' => 'Position X', 'Positon Y' => 'Position Y', - 'description_tagsMenu_x' => 'Position X du sous-menu en pixels (ex. 50px)', - 'description_tagsMenu_y' => 'Position Y du sous-menu en pixels (ex. 50px)', + 'tags_menu_x_description' => 'Position X du sous-menu en pixels (ex. 50px)', + 'tags_menu_y_description' => 'Position Y du sous-menu en pixels (ex. 50px)', - 'description_menu_position' => 'Position du menu', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'N’oubliez pas « https:// » devant les liens de bannière.', - 'description_language' => 'Choisissez votre langue pour l’interface de Berta. Rafraîchissez la page pour pour appliquer les modifications.', + 'menu_position_description' => 'Position du menu', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'N’oubliez pas « https:// » devant les liens de bannière.', + 'language_description' => 'Choisissez votre langue pour l’interface de Berta. Rafraîchissez la page pour pour appliquer les modifications.', 'Heading position' => 'Position de l’entête', - 'description_heading_position' => 'Position de la description d’entête : fixe (Fixed) ou absolue (Absolute). La position fixe restera toujours en place sur la page, la positions absolue se déplace avec le contenu au défilement.', - 'description_submenu_alwaysopen' => 'Un sous-menu est ouvert lorsqu’un élément de menu est actif.', + 'heading_position_description' => 'Position de la description d’entête : fixe (Fixed) ou absolue (Absolute). La position fixe restera toujours en place sur la page, la positions absolue se déplace avec le contenu au défilement.', + 'tags_menu_always_open_description' => 'Un sous-menu est ouvert lorsqu’un élément de menu est actif.', 'Submenu is allways open' => 'Le sous-menu est toujours ouvert', 'Submenu is hidden' => 'Le sous-menu est masqué', - 'javascript_include' => 'Code javascript qui sera inclus avant la balise </body>. Pour en savoir plus visitez notre page consacrée aux inclusions javascript.', - 'description_custom_css' => 'Insérez votre code CSS personnalisé ici. Il surchargera toutes les autres règles précédemment définies. Pour en savoir plus, consultez notre Wiki.', + 'js_include_description' => 'Code javascript qui sera inclus avant la balise </body>. Pour en savoir plus visitez notre page consacrée aux inclusions javascript.', + 'custom_css_description' => 'Insérez votre code CSS personnalisé ici. Il surchargera toutes les autres règles précédemment définies. Pour en savoir plus, consultez notre Wiki.', 'Custom CSS' => 'CSS perso.', 'section_type' => 'Type', 'section_type_tip' => 'Defines the layout and functionality of the section.', @@ -458,4 +413,150 @@ 'shuffle_marked' => 'Shuffle marked entries and galleries', 'entries_limit' => 'Entries limit', 'entries_limit_tip' => 'How many entries to show in this section', + + // shop translations + 'Configuration values' => 'Valeurs de configuration', + 'Shop currency' => 'Devise de la boutique', + 'shop_currency_description' => 'Abréviation ou symbole de la devise affiché avec chaque prix du site.', + 'Order email subject' => 'Objet de l’e-mail de commande', + 'shop_order_email_subject_description' => 'Objet de l’e-mail de commande envoyé au client.', + 'Seller details' => 'Coordonnées du vendeur', + 'shop_seller_details_description' => 'Les coordonnées de votre entreprise et de votre compte bancaire, incluses dans les e-mails de commande. Les clients qui paient par virement bancaire paient sur le compte indiqué ici.', + 'Required.' => 'Obligatoire.', + 'PDF invoices' => 'Factures PDF', + 'shop_invoices_description' => 'Choisissez « Yes » pour joindre une facture PDF à l’e-mail de commande du client et pour télécharger les factures dans « Orders ». Les factures nécessitent les coordonnées du vendeur ci-dessus, avec les coordonnées de votre entreprise et de votre compte bancaire : sans elles, aucune facture n’est créée.', + 'Basket expires' => 'Expiration du panier', + 'shop_basket_expires_minutes_description' => 'Nombre de minutes pendant lesquelles les produits d’un panier restent réservés après la dernière action du client dans la boutique.', + 'Add to basket' => 'Ajouter au panier', + 'shop_add_to_basket_description' => 'Texte du bouton qui ajoute un produit au panier.', + 'Added to basket' => 'Ajouté au panier', + 'shop_added_to_basket_description' => 'Message affiché après l’ajout d’un produit au panier.', + 'Out of stock' => 'Épuisé', + 'shop_out_of_stock_description' => 'Message affiché lorsqu’un produit est épuisé.', + 'Email' => 'E-mail', + 'shop_email_description' => 'Votre adresse e-mail, à laquelle la boutique envoie chaque nouvelle commande.', + 'PayPal and debit or credit card' => 'PayPal et carte de débit ou de crédit', + 'Bank transfer' => 'Virement bancaire', + 'PayPal, debit or credit card and bank transfer' => 'PayPal, carte de débit ou de crédit et virement bancaire', + 'Payment method' => 'Mode de paiement', + 'shop_payment_method_description' => 'Modes de paiement proposés lors de la commande. Les paiements PayPal et par carte nécessitent aussi les identifiants PayPal ci-dessous.', + 'Promo code' => 'Code promo', + 'shop_promo_code_description' => 'Un code qui donne une réduction aux clients. Lorsque ce champ et la réduction ci-dessous sont remplis, le panier demande un code promo.', + 'Promo code discount' => 'Réduction du code promo', + 'shop_promo_code_discount_description' => 'Réduction en pourcentage pour les clients qui saisissent le code promo.', + 'Terms of service link' => 'Lien vers les conditions générales', + 'shop_terms_link_description' => 'Adresse de votre page de conditions générales. Lorsqu’elle est remplie, le panier affiche un lien vers celle-ci avant la commande.', + 'Weight units' => 'Unité de poids', + 'shop_weight_unit_description' => 'Unité de poids des produits, comme g ou oz. Les frais de port sont calculés à partir des poids.', + 'PayPal' => 'PayPal', + 'shop_paypal_description' => 'Pour accepter les paiements par PayPal et par carte de débit ou de crédit, connectez votre compte PayPal professionnel. Remplissez un seul jeu d’identifiants ci-dessous, pas les deux : les identifiants d’app REST (pour une nouvelle configuration) ou les identifiants d’API Classic (boutiques plus anciennes déjà configurées avec eux). Si les deux sont remplis, ce sont les identifiants REST qui sont utilisés.', + 'Currency' => 'Devise', + 'Required for PayPal payments.' => 'Obligatoire pour les paiements PayPal.', + 'shop_paypal_currency_code_description' => 'Code à trois lettres de la devise dans laquelle vos clients paient, par exemple EUR ou USD. Utilisez la devise des prix de votre boutique : si le champ est vide, PayPal facture les mêmes montants en dollars américains, sans les convertir. Devises prises en charge par PayPal', + 'Test mode (sandbox)' => 'Mode test (sandbox)', + 'shop_paypal_sandbox_description' => 'Lorsqu’il est activé, les paiements passent par l’environnement de test de PayPal (sandbox), où aucun argent réel n’est transféré, pour que vous puissiez essayer la commande. L’environnement de test nécessite des identifiants sandbox, différents de vos identifiants réels. Désactivez-le et saisissez vos identifiants réels avant de commencer à vendre.', + 'PayPal REST app credentials (recommended)' => 'Identifiants d’app REST PayPal (recommandé)', + 'shop_paypal_rest_description' => 'Où les trouver : connectez-vous au PayPal Developer Dashboard avec votre compte PayPal professionnel et ouvrez Apps & Credentials. Ouvrez-y votre app, ou cliquez sur Create App pour en créer une, puis copiez son Client ID et son Secret dans les champs ci-dessous. Pour le mode test, copiez-les depuis l’onglet Sandbox au lieu de Live. Guide de PayPal', + 'Client ID' => 'Client ID', + 'shop_paypal_client_id_description' => 'Un long code de lettres et de chiffres, affiché sur la page de votre app.', + 'Secret' => 'Secret', + 'shop_paypal_client_secret_description' => 'Affiché sous le Client ID sur la même page ; faites-le apparaître pour le copier. Gardez-le confidentiel.', + 'PayPal Classic API credentials (older shops)' => 'Identifiants d’API Classic PayPal (anciennes boutiques)', + 'shop_paypal_classic_description' => 'Uniquement pour les boutiques déjà configurées avec eux : laissez ces champs vides si vous utilisez les identifiants d’app REST ci-dessus. Où les trouver : connectez-vous sur paypal.com, allez dans Account Settings › Account access, cliquez sur Update à côté de API access, puis sur Manage API credentials sous NVP/SOAP API integration (Classic). Pour le mode test, faites de même sur sandbox.paypal.com avec un compte professionnel sandbox. Guide de PayPal', + 'API username' => 'Nom d’utilisateur API', + 'shop_paypal_user_name_description' => 'Ressemble à yourname_api1.example.com.', + 'API password' => 'Mot de passe API', + 'API signature' => 'Signature API', + 'shop_paypal_signature_description' => 'Une longue chaîne de lettres et de chiffres.', + 'Shopping cart image' => 'Image du panier', + 'shop_cart_image_description' => 'Image affichée à la place du texte du lien du panier. Taille recommandée : jusqu’à 50 × 50 pixels.', + 'shop_price_item_font_color_description' => 'Couleur du texte du prix affiché sur chaque entrée de produit.', + 'shop_price_item_font_family_description' => 'Fonte du prix du produit. Si une fonte Google est indiquée ci-dessous, elle est utilisée à la place.', + 'Google web fonts' => 'Fontes web Google', + 'shop_price_item_font_size_description' => 'Corps du prix du produit, par exemple 12px ou 1.2em.', + 'shop_price_item_font_weight_description' => 'Affiche le prix du produit en texte normal ou gras.', + 'shop_price_item_font_style_description' => 'Affiche le prix du produit en texte normal ou italique.', + 'shop_price_item_font_variant_description' => 'small-caps affiche les lettres du prix, comme la devise, en petites capitales.', + 'shop_price_item_line_height_description' => 'Hauteur de la ligne de texte du prix du produit, par exemple 14px, 1.2em ou normal.', + 'Shop entry width' => 'Largeur des entrées de la boutique', + 'shop_entry_width_description' => 'Largeur des entrées de produits en pixels. Utilisée uniquement par le modèle Messy lorsque sa mise en page n’est pas responsive. La largeur propre d’une entrée la remplace. Laissez vide pour la largeur par défaut.', + + // Settings and templates + 'Additional text' => 'Texte supplémentaire', + 'Background video (embed)' => 'Vidéo d’arrière-plan (intégrée)', + 'Background video ratio' => 'Proportions de la vidéo d’arrière-plan', + 'Button color' => 'Couleur des boutons', + 'Centered content width' => 'Largeur du contenu centré', + 'Centered contents' => 'Contenu centré', + 'Centered layout' => 'Mise en page centrée', + 'Centering guides color tone' => 'Ton des repères de centrage', + 'Choose "no" to hide page heading in first section.' => 'Choisissez « No » pour masquer l’entête de page dans la première section.', + 'Choose "yes" to show gridlines in background. Available only in Messy template.' => 'Choisissez « Yes » pour afficher une grille en arrière-plan. Disponible uniquement dans le template Messy.', + 'Choose theme' => 'Choisissez un thème', + 'Color of the fullscreen gallery background.' => 'Couleur de fond de la galerie en plein écran.', + 'Color tone for centering guides (dark for bright background colors, bright for dark background colors).' => 'Ton des repères de centrage (sombre pour les fonds clairs, clair pour les fonds sombres).', + 'Column count for Mash-up section.' => 'Nombre de colonnes de la section Mash-up.', + 'Columns' => 'Colonnes', + 'Content width if layout is centered.' => 'Largeur du contenu lorsque la mise en page est centrée.', + 'Custom social media buttons' => 'Boutons de réseaux sociaux personnalisés', + 'Displayed image will be half of the original size, full size will be used for hi-res displays.' => 'L’image affichée fait la moitié de sa taille d’origine ; la taille complète est utilisée sur les écrans haute résolution.', + 'Enable navigation to page top' => 'Activer le retour en haut de page', + 'Enabled' => 'Activé', + 'Enables fullscreen gallery slide numbers.' => 'Active la numérotation des images de la galerie en plein écran.', + 'Enables gallery fullscreen mode for new entries.' => 'Active le mode plein écran de la galerie pour les nouvelles entrées.', + 'Enables responsive layout for mobile devices.' => 'Active la mise en page responsive pour les appareils mobiles.', + 'Entries are organised in columns. Column count can be changed under section settings.' => 'Les entrées sont disposées en colonnes. Le nombre de colonnes se règle dans les paramètres de la section.', + 'Entry max width' => 'Largeur maximale des entrées', + 'Entry padding' => 'Marge intérieure des entrées', + 'Fill window' => 'Remplir la fenêtre', + 'Gallery fullscreen settings:' => 'Paramètres de la galerie en plein écran :', + 'Google Analytics measurement ID' => 'ID de mesure Google Analytics', + 'Google Tag Manager container ID' => 'ID de conteneur Google Tag Manager', + 'Google ownership verification tag. More info.' => 'Balise de validation de propriété Google. En savoir plus.', + 'Grid layout' => 'Mise en page en grille', + 'Gridlines color' => 'Couleur de la grille', + 'HTML code' => 'Code HTML', + 'Heading margin' => 'Marges de l’entête', + 'Hide copyrights' => 'Masquer les mentions de copyright', + 'Hides Berta\'s copyrights' => 'Masque les mentions de copyright de Berta', + 'How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.' => 'Distance entre la sidebar et le bord gauche de l’écran. Ignorée si la mise en page centrée est activée.', + 'How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.' => 'Comment obtenir l’ID de mesure. Laissez vide si l’ID de conteneur Google Tag Manager est rempli.', + 'How to get the container ID. Leave blank if Google Analytics measurement ID is filled.' => 'Comment obtenir l’ID de conteneur. Laissez vide si l’ID de mesure Google Analytics est rempli.', + 'Icon' => 'Icône', + 'Javascript code' => 'Code Javascript', + 'Javascript include' => 'Javascript inclus', + 'Keep ratio' => 'Conserver les proportions', + 'Link' => 'Lien', + 'Link address' => 'Adresse du lien', + 'Location' => 'Emplacement', + 'Location of social media buttons. Content of additional text or additional footer will be replaced.' => 'Emplacement des boutons de réseaux sociaux. Ils remplacent le contenu du texte supplémentaire ou du pied de page supplémentaire.', + 'Margin around menu.' => 'Marge autour du menu.', + 'Margin around page heading or logo.' => 'Marge autour de l’entête de page ou du logo.', + 'Mash-up columns' => 'Colonnes Mash-up', + 'Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Taille maximale d’une image moyenne (visible si « Medium images » est activé dans l’éditeur de galerie). Ces paramètres ne modifient pas l’image d’origine.', + 'Medium image height' => 'Hauteur des images moyennes', + 'Medium image width' => 'Largeur des images moyennes', + 'Menu margin' => 'Marges du menu', + 'Name of your site. Visible in all sections.' => 'Nom de votre site. Visible dans toutes les sections.', + 'Opens in' => 'S’ouvre dans', + 'Page heading, menu items and section entries is horizontally centered to window.' => 'L’entête de page, les éléments du menu et les entrées des sections sont centrés horizontalement dans la fenêtre.', + 'Paste or write your HTML code here.' => 'Collez ou écrivez votre code HTML ici.', + 'Paste or write your Javascript code here.' => 'Collez ou écrivez votre code Javascript ici.', + 'Reset design' => 'Réinitialiser le design', + 'Reset design to default values.' => 'Rétablit les valeurs par défaut du design.', + 'Responsive design' => 'Design responsive', + 'Responsive layout' => 'Mise en page responsive', + 'Set the default state of image number visibility in slideshow galleries.' => 'Définit si les numéros d’image sont affichés par défaut dans les galeries en diaporama.', + 'Sets whether layout should be centered or not.' => 'Définit si la mise en page est centrée ou non.', + 'Sets whether layout should be responsive or not.' => 'Définit si la mise en page est responsive ou non.', + 'Show gridlines' => 'Afficher la grille', + 'Show page heading in first section?' => 'Afficher l’entête de page dans la première section ?', + 'Show slideshow image numbers' => 'Afficher les numéros d’image du diaporama', + 'Shows navigation button at the bottom when page is scrolled.' => 'Affiche un bouton de navigation en bas lorsque la page défile.', + 'Slideshow loop mode' => 'Diaporama en boucle', + 'Social media buttons' => 'Boutons de réseaux sociaux', + 'Social media links' => 'Liens de réseaux sociaux', + 'Texts' => 'Textes', + 'Theme' => 'Thème', + 'Themes are ready made sites with predefined design settings.' => 'Les thèmes sont des sites prêts à l’emploi avec des réglages de design prédéfinis.', ]; diff --git a/engine/lang/lv.php b/engine/lang/lv.php index 58b8117d2..480a4a6b4 100644 --- a/engine/lang/lv.php +++ b/engine/lang/lv.php @@ -6,57 +6,23 @@ // Translations from the top "admin" menu /* menu item */ - 'close this' => 'aizvērt', /* menu item */ - 'my site' => 'mana berta', /* menu item */ - 'sections' => 'sadaļas', /* menu item */ - 'settings' => 'uzstādījumi', /* menu item */ - 'design' => 'dizains', /* menu item */ - 'profile' => 'konts', /* menu item */ - 'log out' => 'izlogoties', - - // Translatins from login / logout window - - 'Logout ok. Please wait...' => 'Lūdzu, uzgaidi...', - 'Log in' => 'Ielogoties', // Translatins from sections editor (sections.php) /* title */ - 'Sections' => 'Sadaļas', /* column */ - 'Title as displayed in main menu' => 'Nosaukums (galvenajā izvēlnē)', /* column */ - 'Type' => 'Veids', /* column */ - 'Details' => 'Uzstādījumi', /* column */ - 'Is published?' => 'Publicēts?', /* column */ - 'Delete' => 'Dzēst', /* button */ - 'create new section' => 'izveidot jaunu sadaļu', /* button */ - 'delete' => 'dzēst', - - 'What are sections?' => 'Kas ir sadaļas?', - 'sections_help_text' => 'Sadaļas ir lapas pamatelementi un to nosaukumi veido galveno izvēlni (menu).', - 'What is the "external link"?' => 'Kas ir "external link" (ārējā saite)?', - 'external_link_help_text' => 'Izvēlnes elements var būt ārējā saite. Piemēram epasta adrese mailto:sombeody@someplace.net) vai saite uz citu mājas lapu (piemēram, http://www.example.com)', - - // Translations from profile page (profile.php) - 'Profile' => 'Profils', - 'Old password' => 'Vecā parole', - 'New password' => 'Jaunā parole', - 'Retype new password' => 'Jaunā parole vēlreiz', - 'Change password' => 'Nomainīt paroli', - 'password_help_text' => 'Parolei jābūt vismaz 6 simbolus garai
              un tai jāsatur tikai lielie/mazie latīņu burti un/vai cipari (A-Z, a-z, 0-9).', - '' => '', // Translations from settings page (settings.php) @@ -64,13 +30,8 @@ 'Settings' => 'Uzstādījumi', /* title */ 'Design' => 'Dizains', - 'These are settings for template' => 'Šie ir uzstādījumi veidnei', - 'Some help with CSS values' => 'CSS vērtību skaidrojums', - 'Units' => 'Vienības', - 'units_help_text' => 'Var izmantot sekojošas vienības:
              px – pikseļi
              em - 1 em = viena burta M platums attiecīgajā šriftā
              % - procenti no šrifta izmēra vai arī procenti no konteinera elementa izmēra', 'Margins' => 'Malas', - 'margins_help_text' => 'Ir 4 veidi kā var ievadīt izmēru apmalēm (margins):
              1 vērtība – iestāda augšējo, labo, apakšējo un kreiso apmales platumu vienādā izmērā. Piemēram, 10px.
              2 vērtības – pirmais cipars iestāda augšējo un apakšējo apmales platumu, bet otrs kreiso un labo malas platumu. Piemēram: 0 5px.
              3 vērtības – pirmā vērtība iestāda augšējās apmales platumu, otra kreiso un labo apmali, bet trešā apakšas apmales platumu. Piemēram: 10px 0 20px.
              4 vērtības - iestāda platumus visām apmalēm sekojošā secībā: augša, labā mala, apakša, kreisā apmale. Piemēram: 10px 0 20px 2px', // Translations from inc.settings.php @@ -83,7 +44,6 @@ 'Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.' => 'Veidnes ir "ādiņas" jeb tēmas, kuras izmaina lapas elementu izvietojumu un izskatu.', /* setting tab */ - 'Info' => 'Vispārīgi', /* setting name */ 'Your name' => 'Tavs vārds', /* help text */ @@ -95,7 +55,7 @@ /* setting name */ ' description' => ' apraksts', /* help text */ - 'Site description. It should not be longer than one or two sentences.' => 'Lapas apraksts. Tam nevajadzētu pārsniegt 2 teikumus.', + 'Short site description. It should not be longer than one or two sentences.' => 'Lapas apraksts. Tam nevajadzētu pārsniegt 2 teikumus.', /* setting tab */ 'Navigation' => 'Navigācija', @@ -174,9 +134,7 @@ /* help text */ 'Set the default state of image number visibility in slideshow galleries.' => 'Nosaka, vai pēc noklusējuma rādīt attēlu nummurus slaidrādes galerijām, vai nē.', /* setting name */ - 'Video player' => 'Video player izskats', /* help text */ - 'Choose between the two visually different players for your video files.' => 'Izvēlies starp diviem dažādiem video player veidiem.', /* setting tab */ 'Banners' => 'Banneri', @@ -195,7 +153,6 @@ // Translations from entries view (and editing) - '

              Congratulations! You have successfully installed Berta.

              Now, before adding your content, you have to create a new section. Go to the sections page and do that!

              ' => '

              Apsveicam! Esi veiksmīgi izveidojis savu web lapu!

              Pirms pievienot saturu, tev jāizveido jauna sadaļa. Dodies uz sadaļas

              ', 'create new entry here' => 'izveidot jaunu ierakstu', 'create new entry' => 'izveidot jaunu ierakstu', @@ -208,7 +165,7 @@ /* setting name */ 'Font face' => 'Burtu šrifts', /* setting name */ - 'Font size' => 'Šritfta izmērs', + 'Font size' => 'Šrifta izmērs', /* setting name */ 'Font weight' => 'Šrifta treknums', /* setting name */ @@ -289,7 +246,6 @@ /* setting name */ 'Header image' => 'Lapas galvenes attēls', /* help text */ - 'Picture to use instead of text.' => 'Attēls, kas tiks izmantots teksta vietā lapas galvenē (header).', /* help text */ 'How far the heading is from other elements in page.' => 'Cik tālu lapas galvene (header) atrodas no citiem elementiem.', /* help text */ @@ -365,7 +321,6 @@ /* setting name */ 'Left margin' => 'Attālums no kreisās malas', /* help text */ - 'How far the sidebar is from the left side of the screen.' => 'Cik tālu sānjosla atrodas no ekrāna kreisās malas?', /* setting name */ 'Top padding' => 'Attālums no augšas', /* help text */ @@ -454,75 +409,90 @@ /* help text */ 'IMPORTANT! This must be set as percentage. i.e. 60%' => 'UZMANĪBU! Šim parametram jābūt ievadītam kā procentiem. Piemēram, 60%', - 'googleFont_description' => 'Ieraksti google fonta nosaukumu. Apskati kādi fonti ir piejami: Google Fonts. Ja izvēlēts google fonts, tad sistēmas fonti netiks ņemti vērā. Piemērs: Marvel, Marvel:700italic vai Josefin Slab:700italic', + 'google_font_description' => 'Ieraksti google fonta nosaukumu. Apskati, kādi fonti ir pieejami: Google Fonts. Ja izvēlēts google fonts, tad sistēmas fonti netiks ņemti vērā. Piemērs: Marvel, Marvel:700italic vai Josefin Slab:700italic', 'Menu position' => 'Izvēlnes pozīcija', 'Positon X' => 'X pozīcija', 'Positon Y' => 'Y pozīcija', - 'description_tagsMenu_x' => 'Apakšizvēlnes X pozīcija pikseļos (piem.: 50px)', - 'description_tagsMenu_y' => 'Apakšizvēlnes Y pozīcija pikseļos (piem.: 50px)', - 'description_menu_position' => 'Izvēlnes pozicionēšana', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'Bannera saite. Adreses priekšā neaizmirsti pievienot https:// (piem.: https://www.berta.me)', - 'description_language' => 'Izvēlies interfeisa valodu.', + 'tags_menu_x_description' => 'Apakšizvēlnes X pozīcija pikseļos (piem.: 50px)', + 'tags_menu_y_description' => 'Apakšizvēlnes Y pozīcija pikseļos (piem.: 50px)', + 'menu_position_description' => 'Izvēlnes pozicionēšana', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'Bannera saite. Adreses priekšā neaizmirsti pievienot https:// (piem.: https://www.berta.me)', + 'language_description' => 'Izvēlies interfeisa valodu.', 'Heading position' => 'Galvenes pozīcija', - 'description_heading_position' => 'Galvenes novietojums, fiksēts vai nefiksēts', - 'description_submenu_alwaysopen' => 'Apakšizvēlnes ir vienmēr atvērtas.', + 'heading_position_description' => 'Galvenes novietojums, fiksēts vai nefiksēts', + 'tags_menu_always_open_description' => 'Apakšizvēlnes ir vienmēr atvērtas.', 'Submenu is allways open' => 'Apakšizvēlne vienmēr atvērta', 'Submenu is hidden' => 'Apakšizvēlnes slēptas', - 'javascript_include' => 'Javascript kods, kurš tiks iekļauts tieši pirms </body> elementa. Uzzini vairāk kā iekļaut JavaScript kodu.', - 'description_custom_css' => 'Ievietojiet savu CSS kodu šeit. Iepriekš eksistējošas CSS definīcijas tiks pārrakstītas. Vairāk informācijai apskatiet mūsu WIKI.', - - // Videos panel & newsticker - 'To enable Berta\'s tutorial videos, your computer needs to be connected to the internet!
              When the internet access is enabled, sign out of engine panel and log in again to view the videos.' => 'Lai skatītu Bertas pamācību video, Jūsu datoram nepieciešama interneta pieeja.
              Kad dators pieslēgts pie interneta, izlogojieties no engine paneļa un ielogojieties no jauna.', - 'Show this window on startup' => 'Vienmēr rādīt šo logu pēc ielogošanās', - 'Close' => 'Aizvērt', - - 'To enable Berta\'s news ticker, your computer needs to be connected to the internet!' => 'Lai redzētu Bertas jaunumus, Jūsu datoram nepieciešama interneta pieeja!', + 'js_include_description' => 'Javascript kods, kurš tiks iekļauts tieši pirms </body> elementa. Uzzini vairāk kā iekļaut JavaScript kodu.', + 'custom_css_description' => 'Ievietojiet savu CSS kodu šeit. Iepriekš eksistējošas CSS definīcijas tiks pārrakstītas. Vairāk informācijai apskatiet mūsu WIKI.', // shop translations - 'Shop' => 'Veikals', - 'shop' => 'veikals', - 'Inventory' => 'Noliktava', - 'Regional costs' => 'Reģionālās izmaksas', - 'Item name' => 'Vienības nosaukums', - 'Price' => 'Cena', - 'price' => 'cena', - 'In stock' => 'Noliktavā', - 'Reservation' => 'Rezervēts', - 'if weight is less than' => 'ja svars ir mazāks par', - 'weight' => 'svars', - 'then price is' => 'tad cena ir', - 'region name' => 'reģiona nosaukums', - 'vat' => 'pvn', - '+ add new costs condition' => '+ pievienot jaunu izmaksu nosacījumu', - '+ add new region' => '+ pievienot jaunu reģionu', - 'Save' => 'Saglabāt', 'Configuration values' => 'Konfigurācijas vērtības', '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.', + 'shop_currency_description' => 'Valūtas saīsinājums vai simbols, kas redzams pie katras cenas vietnē.', + 'Order email subject' => 'Pasūtījuma e-pasta temats', + 'shop_order_email_subject_description' => 'Pircējam nosūtītā pasūtījuma e-pasta temats.', + 'Required.' => 'Obligāts.', '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ā.', + 'shop_seller_details_description' => 'Tava uzņēmuma un bankas konta rekvizīti, kas tiek iekļauti pasūtījumu e-pastos. Pircēji, 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.', + 'shop_add_to_basket_description' => 'Pogas, kas pievieno preci grozam, teksts.', 'Out of stock' => 'Noliktavā nav', - 'Out of stock text' => 'Paziņojuma "Noliktavā nav" teksts.', - 'Email' => 'e-pasts', - 'Email where send order data' => 'e-pasts, uz kuru sūtīt pasūtījuma datus.', + 'shop_out_of_stock_description' => 'Paziņojums, kas tiek parādīts, kad prece ir izpārdota.', + 'Email' => 'E-pasts', + 'shop_email_description' => 'Tava e-pasta adrese, uz kuru veikals sūta katru jauno pasūtījumu.', 'Payment method' => 'Maksājuma metode', - 'Payment method.' => 'Maksājuma metode.', + 'shop_payment_method_description' => 'Pirkuma noformēšanā piedāvātās maksājuma metodes. PayPal un karšu maksājumiem vajadzīgi arī zemāk esošie PayPal piekļuves dati.', 'Promo code' => 'Atlaides kods', - 'If Yes - promo code entry will be displayed.' => 'Ja lauciņš ir aizpildīts, tad pircējam būs iespēja ievadīt atlaides kodu.', + 'shop_promo_code_description' => 'Kods, ar kuru pircēji saņem atlaidi. Ja aizpildīts gan tas, gan zemāk esošā atlaide, grozā tiek prasīts atlaides kods.', 'Promo code discount' => 'Atlaides apjoms', - 'Discount in precents when user enters valid promo code' => 'Atlaides procentuāls apjoms uz precēm, ja pircējs ievadījis pareizu kodu.', + 'shop_promo_code_discount_description' => 'Atlaide procentos pircējiem, kuri ievada atlaides kodu.', 'Weight units' => 'Svara mērvienība', - 'Weight units (g, oz) used for postage cost calculation.' => 'Svara mērvienība (g, oz), kas tie lietota pie pasta izdevumu aprēķina.', + 'shop_weight_unit_description' => 'Preču svara mērvienība, piemēram, g vai oz. Pēc svara tiek aprēķinātas piegādes izmaksas.', 'Shop entry width' => 'Veikala ieraksta platums', - 'Title' => 'Virsraksts', - 'What is SEO?' => 'Kas ir SEO?', - 'seo_help_text' => 'Search Engine Optimization (SEO) palīdz uzlabot mājaslapas reitingu Google meklēšanas rezultātos. Uzzini vairāk, kā tas strādā.', - 'Content description' => 'Satura apraksts', + 'PDF invoices' => 'PDF rēķini', + 'shop_invoices_description' => 'Izvēlies "Yes", lai klienta pasūtījuma e-pastam pievienotu PDF rēķinu un rēķinus varētu lejupielādēt sadaļā "Orders". Rēķiniem vajadzīgi augstāk norādītie tirgotāja rekvizīti ar tava uzņēmuma un bankas konta datiem: bez tiem rēķins netiek izveidots.', + 'Basket expires' => 'Groza derīgums', + 'shop_basket_expires_minutes_description' => 'Cik minūtes preces grozā paliek rezervētas pēc pircēja pēdējās darbības veikalā.', + 'Added to basket' => 'Pievienots grozam', + 'shop_added_to_basket_description' => 'Paziņojums, kas tiek parādīts pēc preces pievienošanas grozam.', + 'PayPal and debit or credit card' => 'PayPal un debetkarte vai kredītkarte', + 'Bank transfer' => 'Bankas pārskaitījums', + 'PayPal, debit or credit card and bank transfer' => 'PayPal, debetkarte vai kredītkarte un bankas pārskaitījums', + 'Terms of service link' => 'Saite uz lietošanas noteikumiem', + 'shop_terms_link_description' => 'Tavas lietošanas noteikumu lapas adrese. Ja aizpildīta, grozā pirms pirkuma noformēšanas tiek parādīta saite uz to.', + 'PayPal' => 'PayPal', + 'shop_paypal_description' => 'Lai pieņemtu maksājumus ar PayPal un debetkarti vai kredītkarti, pievieno savu PayPal biznesa kontu. Aizpildi vienu no zemāk esošajiem piekļuves datu komplektiem, nevis abus: REST lietotnes piekļuves datus (jaunai uzstādīšanai) vai Classic API piekļuves datus (vecākiem veikaliem, kas jau ar tiem uzstādīti). Ja aizpildīti abi, tiek lietoti REST dati.', + 'Currency' => 'Valūta', + 'Required for PayPal payments.' => 'Obligāts PayPal maksājumiem.', + 'shop_paypal_currency_code_description' => 'Trīs burtu kods valūtai, kurā maksā tavi pircēji, piemēram, EUR vai USD. Norādi savu veikala cenu valūtu: ja lauks atstāts tukšs, PayPal iekasē tās pašas summas ASV dolāros, tās nekonvertējot. PayPal atbalstītās valūtas', + 'Test mode (sandbox)' => 'Testa režīms (sandbox)', + 'shop_paypal_sandbox_description' => 'Kad ieslēgts, maksājumi notiek PayPal testa vidē (sandbox), kur īsta nauda netiek pārskaitīta, tāpēc vari izmēģināt pirkuma noformēšanu. Testa videi vajadzīgi testa vides piekļuves dati, kas atšķiras no īstajiem. Pirms sāc pārdot, izslēdz testa režīmu un ievadi īstos piekļuves datus.', + 'PayPal REST app credentials (recommended)' => 'PayPal REST lietotnes piekļuves dati (ieteicami)', + 'shop_paypal_rest_description' => 'Kur tos atrast: ar savu PayPal biznesa kontu ielogojies PayPal Developer Dashboard un atver Apps & Credentials. Tur atver savu lietotni vai uzklikšķini Create App, lai to izveidotu, un iekopē tās Client ID un Secret zemāk esošajos laukos. Testa režīmam kopē tos no cilnes Sandbox, nevis Live. PayPal pamācība', + 'Client ID' => 'Client ID', + 'shop_paypal_client_id_description' => 'Garš burtu un ciparu kods, kas redzams tavas lietotnes lapā.', + 'Secret' => 'Secret', + 'shop_paypal_client_secret_description' => 'Redzams zem Client ID tajā pašā lapā; lai to nokopētu, parādi to. Glabā to slepenībā.', + 'PayPal Classic API credentials (older shops)' => 'PayPal Classic API piekļuves dati (vecākiem veikaliem)', + 'shop_paypal_classic_description' => 'Tikai veikaliem, kas jau ar tiem uzstādīti: atstāj šos laukus tukšus, ja lieto augstāk esošos REST lietotnes piekļuves datus. Kur tos atrast: ielogojies paypal.com, atver Account Settings › Account access, uzklikšķini Update pie API access un tad Manage API credentials sadaļā NVP/SOAP API integration (Classic). Testa režīmam dari to pašu vietnē sandbox.paypal.com ar testa vides biznesa kontu. PayPal pamācība', + 'API username' => 'API lietotājvārds', + 'shop_paypal_user_name_description' => 'Izskatās šādi: yourname_api1.example.com.', + 'API password' => 'API parole', + 'API signature' => 'API paraksts', + 'shop_paypal_signature_description' => 'Gara burtu un ciparu virkne.', + 'Shopping cart image' => 'Iepirkumu groza attēls', + 'shop_cart_image_description' => 'Attēls, ko rādīt iepirkumu groza saites teksta vietā. Ieteicamais izmērs: līdz 50 × 50 pikseļiem.', + 'shop_price_item_font_color_description' => 'Cenas teksta krāsa katrā preces ierakstā.', + 'shop_price_item_font_family_description' => 'Preces cenas šrifts. Ja zemāk norādīts Google fonts, tiek lietots tas.', + 'Google web fonts' => 'Google tīmekļa fonti', + 'shop_price_item_font_size_description' => 'Preces cenas izmērs, piemēram, 12px vai 1.2em.', + 'shop_price_item_font_weight_description' => 'Rāda preces cenu parastā vai treknā tekstā.', + 'shop_price_item_font_style_description' => 'Rāda preces cenu parastā tekstā vai slīprakstā.', + 'shop_price_item_font_variant_description' => 'Ar small-caps cenas burti, piemēram, valūta, tiek rādīti kā kapiteļi (mazi lielie burti).', + 'shop_price_item_line_height_description' => 'Preces cenas teksta rindas augstums, piemēram, 14px, 1.2em vai normal.', + 'shop_entry_width_description' => 'Preču ierakstu platums pikseļos. Tiek lietots tikai Messy veidnē, ja tās izkārtojums nav responsīvs. Ieraksta paša platums to aizstāj. Atstāj tukšu, lai lietotu noklusējuma platumu.', 'section_type' => 'Veids', 'section_type_tip' => 'Nosaka sadaļas funkcionalitāti un satura izklājumu.', 'select_on' => 'Ieslēgts', @@ -530,4 +500,73 @@ 'shuffle_marked' => 'Rādīt ierakstus un galerijas jauktā secībā', 'entries_limit' => 'Ierakstu ierobežojums', 'entries_limit_tip' => 'Cik ierakstus rādīt šajā sadaļā.', + + // Settings and templates + 'Additional text' => 'Papildu teksts', + 'Background video (embed)' => 'Fona video (iegults)', + 'Background video ratio' => 'Fona video proporcijas', + 'Button color' => 'Pogas krāsa', + 'Centered content width' => 'Centrētā satura platums', + 'Centered contents' => 'Centrēts saturs', + 'Centered layout' => 'Centrēts izkārtojums', + 'Centering guides color tone' => 'Centrēšanas palīglīniju tonis', + 'Choose "no" to hide page heading in first section.' => 'Izvēlies "No", lai paslēptu lapas virsrakstu pirmajā sadaļā.', + 'Choose "yes" to show gridlines in background. Available only in Messy template.' => 'Izvēlies "Yes", lai fonā rādītu režģa līnijas. Pieejams tikai Messy veidnē.', + 'Choose theme' => 'Izvēlies tēmu', + 'Color tone for centering guides (dark for bright background colors, bright for dark background colors).' => 'Centrēšanas palīglīniju tonis (tumšs gaišām fona krāsām, gaišs tumšām fona krāsām).', + 'Column count for Mash-up section.' => 'Kolonnu skaits Mash-up sadaļā.', + 'Columns' => 'Kolonnas', + 'Content width if layout is centered.' => 'Satura platums, ja izkārtojums ir centrēts.', + 'Custom CSS' => 'Pielāgots CSS', + 'Custom social media buttons' => 'Pielāgotas sociālo tīklu pogas', + 'Displayed image will be half of the original size, full size will be used for hi-res displays.' => 'Attēls tiek rādīts uz pusi mazāks par oriģinālu, augstas izšķirtspējas ekrāniem tiek izmantots pilnais izmērs.', + 'Enabled' => 'Ieslēgts', + 'Enables responsive layout for mobile devices.' => 'Ieslēdz responsīvo izkārtojumu mobilajām ierīcēm.', + 'Entries are organised in columns. Column count can be changed under section settings.' => 'Ieraksti tiek kārtoti kolonnās. Kolonnu skaitu var mainīt sadaļas uzstādījumos.', + 'Entry max width' => 'Ieraksta maksimālais platums', + 'Entry padding' => 'Ieraksta iekšējās atkāpes', + 'Fill window' => 'Aizpildīt logu', + 'Google Analytics measurement ID' => 'Google Analytics mērījuma ID', + 'Google Tag Manager container ID' => 'Google Tag Manager konteinera ID', + 'Google ownership verification tag. More info.' => 'Google īpašumtiesību apstiprināšanas tags. Vairāk informācijas.', + 'Google site verification tag' => 'Google vietnes apstiprināšanas tags', + 'Grid layout' => 'Režģa izkārtojums', + 'Gridlines color' => 'Režģa līniju krāsa', + 'HTML code' => 'HTML kods', + 'Heading margin' => 'Virsraksta malas', + 'Hide copyrights' => 'Paslēpt autortiesības', + 'Hides Berta\'s copyrights' => 'Paslēpj Bertas autortiesību norādi', + 'How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.' => 'Cik tālu sānjosla ir no ekrāna kreisās malas. Netiek ņemts vērā, ja ieslēgts centrēts izkārtojums.', + 'How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.' => 'Kā iegūt mērījuma ID. Atstāj tukšu, ja aizpildīts Google Tag Manager konteinera ID.', + 'How to get the container ID. Leave blank if Google Analytics measurement ID is filled.' => 'Kā iegūt konteinera ID. Atstāj tukšu, ja aizpildīts Google Analytics mērījuma ID.', + 'Icon' => 'Ikona', + 'Javascript code' => 'Javascript kods', + 'Javascript include' => 'Javascript iekļaušana', + 'Keep ratio' => 'Saglabāt proporcijas', + 'Link' => 'Saite', + 'Link address' => 'Saites adrese', + 'Location' => 'Novietojums', + 'Location of social media buttons. Content of additional text or additional footer will be replaced.' => 'Sociālo tīklu pogu novietojums. Papildu teksta vai papildu kājenes saturs tiks aizstāts.', + 'Margin around menu.' => 'Atstarpe ap izvēlni.', + 'Margin around page heading or logo.' => 'Atstarpe ap lapas virsrakstu vai logo.', + 'Mash-up columns' => 'Mash-up kolonnas', + 'Menu margin' => 'Izvēlnes malas', + 'Name of your site. Visible in all sections.' => 'Tavas vietnes nosaukums. Redzams visās sadaļās.', + 'Opens in' => 'Atveras', + 'Page heading, menu items and section entries is horizontally centered to window.' => 'Lapas virsraksts, izvēlnes punkti un sadaļu ieraksti ir horizontāli centrēti logā.', + 'Paste or write your HTML code here.' => 'Ielīmē vai ieraksti šeit savu HTML kodu.', + 'Paste or write your Javascript code here.' => 'Ielīmē vai ieraksti šeit savu Javascript kodu.', + 'Reset design' => 'Atiestatīt dizainu', + 'Reset design to default values.' => 'Atiestatīt dizainu uz noklusējuma vērtībām.', + 'Responsive design' => 'Responsīvs dizains', + 'Responsive layout' => 'Responsīvs izkārtojums', + 'Sets whether layout should be centered or not.' => 'Nosaka, vai izkārtojums ir centrēts.', + 'Sets whether layout should be responsive or not.' => 'Nosaka, vai izkārtojums ir responsīvs.', + 'Show gridlines' => 'Rādīt režģa līnijas', + 'Show page heading in first section?' => 'Rādīt lapas virsrakstu pirmajā sadaļā?', + 'Social media buttons' => 'Sociālo tīklu pogas', + 'Social media links' => 'Sociālo tīklu saites', + 'Texts' => 'Teksti', + 'Theme' => 'Tēma', + 'Themes are ready made sites with predefined design settings.' => 'Tēmas ir gatavas vietnes ar iepriekš noteiktiem dizaina uzstādījumiem.', ]; diff --git a/engine/lang/nl.php b/engine/lang/nl.php index ba5d21005..06873965a 100644 --- a/engine/lang/nl.php +++ b/engine/lang/nl.php @@ -6,57 +6,23 @@ // Translations from the top "admin" menu /* menu item */ - 'close this' => 'sluiten', /* menu item */ - 'my site' => 'mijn site', /* menu item */ - 'sections' => 'sections', /* menu item */ - 'settings' => 'instellingen', /* menu item */ - 'design' => 'design', /* menu item */ - 'profile' => 'profiel', /* menu item */ - 'log out' => 'uitloggen', - - // Translatins from login / logout window - - 'Logout ok. Please wait...' => 'Uitloggen. Wacht even A.U.B.…', - 'Log in' => 'Inloggen', // Translatins from sections editor (sections.php) /* title */ - 'Sections' => 'Sections', /* column */ - 'Title as displayed in main menu' => 'Titel zoals die in het hoofdmenu verschijnt', /* column */ - 'Type' => 'Type', /* column */ - 'Details' => 'Details', /* column */ - 'Is published?' => 'is het gepubliceerd?', /* column */ - 'Delete' => 'Delete', /* button */ - 'create new section' => 'Maak nieuwe Section', /* button */ - 'delete' => 'Delete', - - 'What are sections?' => 'Wat zijn Sections?', - 'sections_help_text' => 'Sections zijn de grootste of grofste onderverdelingen in jouw site. Je kan ze zien als containers voor jouw content (beeld, tekst, video). Sections verschijnen dus menu-items in het main menu.', - 'What is the "external link"?' => 'Wat is een external link?', - 'external_link_help_text' => 'Als je een menu item een link naar een andere site wilt laten zijn moet je de externe link exact ingeven. Dit kan een email link zijn (bijv., mailto:somebody@someplace.net) of een link naar een andere website (bijv. http://www.artez.nl).', - - // Translations from profile page (profile.php) - 'Profile' => 'Profiel', - 'Old password' => 'Oud wachtwoord', - 'New password' => 'Nieuw wachtwoord', - 'Retype new password' => 'Bevestig nieuw wachtwoord', - 'Change password' => 'Verander wachtwoord', - 'password_help_text' => 'Het wachtwoord moet tenminste 6 karakters bevatten
              en alphanumerieke (A-Z, a-z, 0-9) karakters, maar geen leestekens zoals *&%$?.:;/, en ook geen spaties.', - '' => '', // Translations from settings page (settings.php) @@ -64,13 +30,8 @@ 'Settings' => 'Instellingen', /* title */ 'Design' => 'Design', - 'These are settings for template' => 'Dit zijn de instellingen voor de Template', - 'Some help with CSS values' => 'Hulp met CSS waarden (units)', - 'Units' => 'Units', - 'units_help_text' => 'Correcte Units (eenheden) om numerieke waarden in te geven zijn:
              px - pixels. Voorbeeld: 10px. Als je alleen nummers invoert veranderen de waarden NIET.
              em - 1em = een breedte van de letter M in het gebruikte font. Voorbeeld: 1em
              % - procent van de fontsize of procent van de afmetingen van het container element (bijv. the page etc.)', 'Margins' => 'Margins', - 'margins_help_text' => 'Margins kunnen lastig zijn. Gebruik px of em als units. Je kan op 4 manieren margins aangeven, door in te geven:
              1 waarde - zet top, right, bottom en left margins naar dezelfde waarde. Voorbeeld: 10px.
              2 waarden - zet top en bottom margins naar de eerste waarde, left en right - naar de tweede. Voorbeeld: 0 5px.
              3 waarden - zet top margin naar de eerste waarde, left en right - naar de tweede, bottom - naar de derde waarde. Voorbeeld: 10px 0 20px.
              4 waarden - zet alle margins in deze volgorde: top, right, bottom, left. Voorbeeld: 10px 0 20px 2px.', // Translations from inc.settings.php @@ -83,7 +44,6 @@ 'Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.' => 'Templates zijn als een Theme of Skin voor jouw site. Je kan een Template kiezen uit degene die in jouw Templates folder zijn geinstalleerd (Settings>Templates). Om zelf een nieuwe Template toe te voegen aan deze lijst moet je die uploaden via FTP.', /* setting tab */ - 'Info' => 'Info', /* setting name */ 'Your name' => 'Jouw naam', /* help text */ @@ -155,9 +115,7 @@ /* help text */ 'Set the default state of image number visibility in slideshow galleries.' => 'Zijn de image numbers zichtbaar in de slide show?', /* setting name */ - 'Video player' => 'Video player', /* help text */ - 'Choose between the two visually different players for your video files.' => 'Maak hier een keuze tussen twee qua uiterlijk verschillende videoplayers voor jouw videobestanden.', /* setting tab */ 'Banners' => 'Banners', @@ -177,7 +135,7 @@ /* setting tab */ 'Location' => 'Plaatsing', /* setting name */ - 'Location of social media buttons. Content of addition text or addition footer will be replaced.' => 'Plaatsing van de social media buttons. De inhoud van additional text of additional footer text wordt door deze inhoud vervangen.', + 'Location of social media buttons. Content of additional text or additional footer will be replaced.' => 'Plaatsing van de social media buttons. De inhoud van additional text of additional footer text wordt door deze inhoud vervangen.', /* setting tab */ 'Language' => 'Taal', @@ -191,7 +149,6 @@ // Translations from entries view (and editing) - '

              Congratulations! You have successfully installed Berta.

              Now, before adding your content, you have to create a new section. Go to the sections page and do that!

              ' => '

              Gefeliciteerd! Berta is geinstalleerd.

              Voor dat je content gaat toevoegen, moet je eerst een Section maken. Ga naar depagina om dit te doen Nu aan de gang!

              ', 'create new entry here' => 'maak hier een nieuwe entry', 'create new entry' => 'maak een nieuwe entry', @@ -200,19 +157,19 @@ /* setting tab */ 'General font settings' => 'Algemene font settings', /* setting name */ - 'Color' => 'Color', + 'Color' => 'Kleur', /* setting name */ - 'Font face' => 'Font face', + 'Font face' => 'Lettertype', /* setting name */ - 'Font size' => 'Font size', + 'Font size' => 'Lettergrootte', /* setting name */ - 'Font weight' => 'Bold', + 'Font weight' => 'Letterdikte', /* setting name */ - 'Font style' => 'Italic', + 'Font style' => 'Letterstijl', /* setting name */ - 'Font variant' => 'SmallCaps', + 'Font variant' => 'Lettervariant', /* setting name */ - 'Line height' => 'Line height', + 'Line height' => 'Regelhoogte', /* help text */ 'Height of text line. Use em, px or % values or the default value "normal"' => 'Ruimte tussen de regels tekst. Gebruik de units (eenheden) em, px of %, of de standaard waarde « normal ».', @@ -296,7 +253,6 @@ /* setting name */ 'Header image' => 'Header image', /* help text */ - 'Picture to use instead of text.' => 'Image Die je wilt gebruiken in plaats van tekst.', /* help text */ 'How far the heading is from other elements in page.' => 'Hoe ver is de heading van de andere elementen verwijderd? Zie ook korte CSS gids onderaan de pagina.', /* help text */ @@ -461,24 +417,24 @@ /* help text */ 'IMPORTANT! This must be set as percentage. i.e. 60%' => 'Belangrijk! Dit moet als percentage worden ingevoerd, bijv. 60%', - 'googleFont_description' => 'Type of copy/paste een Google font naam. Beschikbare fonts vind je hier: Google Fonts. Let op: Google font overschrijft systeem font. Vakje leeglaten als je een systeem font wilt gebruiken. Voorbeeld: Marvel, Marvel:700italic of Josefin Slab:700italic', + 'google_font_description' => 'Type of copy/paste een Google font naam. Beschikbare fonts vind je hier: Google Fonts. Let op: Google font overschrijft systeem font. Vakje leeglaten als je een systeem font wilt gebruiken. Voorbeeld: Marvel, Marvel:700italic of Josefin Slab:700italic', 'Menu position' => 'Menu position', 'Positon X' => 'Positie X', 'Positon Y' => 'Positie Y', - 'description_tagsMenu_x' => 'Submenu X positie in pixels (bijv. 10px)', - 'description_tagsMenu_y' => 'Submenu Y positie in pixels (bijv. 10px)', - 'description_menu_position' => 'Menu positie', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'Banner link \'https://\' moet zich voor het adres bevinden', - 'description_language' => 'Kies hier de taal van de Berta interface. Refresh site om toe te passen.', - 'Heading position' => 'Heading positie fixed of absolute. Fixed blijft altijd op dezelfde plaats, absolute beweegt mee met de content.', - 'description_heading_position' => 'Heading positie fixed of absolute. Fixed blijft altijd op dezelfde plaats, absolute beweegt mee met de content.', - 'description_submenu_alwaysopen' => 'Submenu is open als het menu item geselecteerd is.', + 'tags_menu_x_description' => 'Submenu X positie in pixels (bijv. 10px)', + 'tags_menu_y_description' => 'Submenu Y positie in pixels (bijv. 10px)', + 'menu_position_description' => 'Menu positie', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'Banner link \'https://\' moet zich voor het adres bevinden', + 'language_description' => 'Kies hier de taal van de Berta interface. Refresh site om toe te passen.', + 'Heading position' => 'Positie van de kop', + 'heading_position_description' => 'Heading positie fixed of absolute. Fixed blijft altijd op dezelfde plaats, absolute beweegt mee met de content.', + 'tags_menu_always_open_description' => 'Submenu is open als het menu item geselecteerd is.', 'Submenu is allways open' => 'Submenu is altijd open', 'Submenu is hidden' => 'Submenu is verborgen', - 'javascript_include' => 'Javascript code invoegen voor closing </body> element. Leer hier meer over include JavaScript code.', - 'description_custom_css' => 'Plak jouw custom CSS code hier. Bestaande CSS definities worden overschreven. Voor meer informatie ga naar onze WIKI.', + 'js_include_description' => 'Javascript code invoegen voor closing </body> element. Leer hier meer over include JavaScript code.', + 'custom_css_description' => 'Plak jouw custom CSS code hier. Bestaande CSS definities worden overschreven. Voor meer informatie ga naar onze WIKI.', 'Custom CSS' => 'Custom CSS.', 'section_type' => 'Type', 'section_type_tip' => 'Defines the layout and functionality of the section.', @@ -487,4 +443,135 @@ 'shuffle_marked' => 'Shuffle marked entries and galleries', 'entries_limit' => 'Entries limit', 'entries_limit_tip' => 'How many entries to show in this section', + + // shop translations + 'Configuration values' => 'Configuratiewaarden', + 'Shop currency' => 'Valuta van de winkel', + 'shop_currency_description' => 'Afkorting of symbool van de valuta, getoond bij elke prijs op de site.', + 'Order email subject' => 'Onderwerp van de bestelmail', + 'shop_order_email_subject_description' => 'Onderwerp van de bestelmail die naar de klant wordt gestuurd.', + 'Seller details' => 'Verkopergegevens', + 'shop_seller_details_description' => 'Je bedrijfs- en bankrekeninggegevens, opgenomen in de bestelmails. Klanten die per bankoverschrijving betalen, betalen naar de rekening die hier staat.', + 'Required.' => 'Verplicht.', + 'PDF invoices' => 'PDF-facturen', + 'shop_invoices_description' => 'Kies "Yes" om een PDF-factuur aan de bestelmail van de klant toe te voegen en facturen te downloaden in "Orders". Facturen hebben de verkopergegevens hierboven nodig, met je bedrijfs- en bankrekeninggegevens: zonder die gegevens wordt er geen factuur gemaakt.', + 'Basket expires' => 'Winkelmand verloopt', + 'shop_basket_expires_minutes_description' => 'Aantal minuten dat de producten in een winkelmand gereserveerd blijven na de laatste actie van de klant in de winkel.', + 'Add to basket' => 'Toevoegen aan winkelmand', + 'shop_add_to_basket_description' => 'Tekst van de knop die een product aan de winkelmand toevoegt.', + 'Added to basket' => 'Toegevoegd aan winkelmand', + 'shop_added_to_basket_description' => 'Bericht dat verschijnt nadat een product aan de winkelmand is toegevoegd.', + 'Out of stock' => 'Uitverkocht', + 'shop_out_of_stock_description' => 'Bericht dat verschijnt als een product uitverkocht is.', + 'Email' => 'E-mail', + 'shop_email_description' => 'Je e-mailadres, waar de winkel elke nieuwe bestelling naartoe stuurt.', + 'PayPal and debit or credit card' => 'PayPal en debet- of creditcard', + 'Bank transfer' => 'Bankoverschrijving', + 'PayPal, debit or credit card and bank transfer' => 'PayPal, debet- of creditcard en bankoverschrijving', + 'Payment method' => 'Betaalmethode', + 'shop_payment_method_description' => 'Betaalmethoden die bij het afrekenen worden aangeboden. Voor betalingen met PayPal en met een kaart zijn ook de PayPal-gegevens hieronder nodig.', + 'Promo code' => 'Kortingscode', + 'shop_promo_code_description' => 'Een code die klanten korting geeft. Als deze code en de korting hieronder zijn ingevuld, vraagt de winkelmand om een kortingscode.', + 'Promo code discount' => 'Korting van de kortingscode', + 'shop_promo_code_discount_description' => 'Korting in procenten voor klanten die de kortingscode invoeren.', + 'Terms of service link' => 'Link naar de algemene voorwaarden', + 'shop_terms_link_description' => 'Adres van je pagina met algemene voorwaarden. Als dit is ingevuld, toont de winkelmand er vóór het afrekenen een link naar.', + 'Weight units' => 'Gewichtseenheid', + 'shop_weight_unit_description' => 'Eenheid van het productgewicht, zoals g of oz. De verzendkosten worden berekend op basis van het gewicht.', + 'PayPal' => 'PayPal', + 'shop_paypal_description' => 'Koppel je zakelijke PayPal-account om betalingen met PayPal en met een debet- of creditcard te ontvangen. Vul één set gegevens hieronder in, niet beide: de REST-app-gegevens (gebruik deze voor een nieuwe installatie) of de Classic API-gegevens (oudere winkels die er al mee zijn ingesteld). Als beide zijn ingevuld, worden de REST-gegevens gebruikt.', + 'Currency' => 'Valuta', + 'Required for PayPal payments.' => 'Verplicht voor PayPal-betalingen.', + 'shop_paypal_currency_code_description' => 'Drieletterige code van de valuta waarin je klanten betalen, bijvoorbeeld EUR of USD. Gebruik de valuta van je winkelprijzen: als dit leeg blijft, rekent PayPal dezelfde bedragen in Amerikaanse dollars af, zonder ze om te rekenen. Valuta die PayPal ondersteunt', + 'Test mode (sandbox)' => 'Testmodus (sandbox)', + 'shop_paypal_sandbox_description' => 'Als deze aan staat, gaan betalingen naar de testomgeving van PayPal (sandbox), waar geen echt geld wordt overgemaakt, zodat je het afrekenen kunt uitproberen. De testomgeving heeft sandbox-gegevens nodig, die verschillen van je echte gegevens. Zet de testmodus uit en vul je echte gegevens in voordat je begint te verkopen.', + 'PayPal REST app credentials (recommended)' => 'PayPal REST-app-gegevens (aanbevolen)', + 'shop_paypal_rest_description' => 'Waar je ze vindt: log met je zakelijke PayPal-account in op het PayPal Developer Dashboard en open Apps & Credentials. Open daar je app, of klik op Create App om er een te maken, en kopieer de Client ID en het Secret naar de velden hieronder. Kopieer ze voor de testmodus uit het tabblad Sandbox in plaats van Live. Handleiding van PayPal', + 'Client ID' => 'Client ID', + 'shop_paypal_client_id_description' => 'Een lange code van letters en cijfers, te zien op de pagina van je app.', + 'Secret' => 'Secret', + 'shop_paypal_client_secret_description' => 'Staat onder de Client ID op dezelfde pagina; maak het zichtbaar om het te kopiëren. Houd het geheim.', + 'PayPal Classic API credentials (older shops)' => 'PayPal Classic API-gegevens (oudere winkels)', + 'shop_paypal_classic_description' => 'Alleen voor winkels die er al mee zijn ingesteld: laat deze velden leeg als je de REST-app-gegevens hierboven gebruikt. Waar je ze vindt: log in op paypal.com, ga naar Account Settings › Account access, klik op Update naast API access en daarna op Manage API credentials onder NVP/SOAP API integration (Classic). Doe voor de testmodus hetzelfde op sandbox.paypal.com met een zakelijk sandbox-account. Handleiding van PayPal', + 'API username' => 'API-gebruikersnaam', + 'shop_paypal_user_name_description' => 'Ziet eruit als yourname_api1.example.com.', + 'API password' => 'API-wachtwoord', + 'API signature' => 'API-handtekening', + 'shop_paypal_signature_description' => 'Een lange reeks letters en cijfers.', + 'Shopping cart image' => 'Afbeelding van de winkelmand', + 'shop_cart_image_description' => 'Afbeelding die in plaats van de linktekst van de winkelmand wordt getoond. Aanbevolen formaat: maximaal 50 × 50 pixels.', + 'shop_price_item_font_color_description' => 'Tekstkleur van de prijs bij elk product.', + 'shop_price_item_font_family_description' => 'Lettertype van de productprijs. Als hieronder een Google-font is ingevuld, wordt dat gebruikt.', + 'Google web fonts' => 'Google-webfonts', + 'shop_price_item_font_size_description' => 'Grootte van de productprijs, bijvoorbeeld 12px of 1.2em.', + 'shop_price_item_font_weight_description' => 'Toont de productprijs in normale of vette tekst.', + 'shop_price_item_font_style_description' => 'Toont de productprijs in normale of cursieve tekst.', + 'shop_price_item_font_variant_description' => 'small-caps toont de letters in de prijs, zoals de valuta, als kleinkapitalen.', + 'shop_price_item_line_height_description' => 'Hoogte van de tekstregel van de productprijs, bijvoorbeeld 14px, 1.2em of normal.', + 'Shop entry width' => 'Breedte van winkelitems', + 'shop_entry_width_description' => 'Breedte van productitems in pixels. Alleen gebruikt door de Messy-template als de lay-out niet responsive is. De eigen breedte van een item gaat voor. Laat leeg voor de standaardbreedte.', + + // Settings and templates + 'Additional text' => 'Extra tekst', + 'Background video (embed)' => 'Achtergrondvideo (ingesloten)', + 'Background video ratio' => 'Verhouding van de achtergrondvideo', + 'Button color' => 'Knopkleur', + 'Centered contents' => 'Gecentreerde inhoud', + 'Choose "no" to hide page heading in first section.' => 'Kies "No" om de page heading in de eerste sectie te verbergen.', + 'Choose "yes" to show gridlines in background. Available only in Messy template.' => 'Kies "Yes" om rasterlijnen in de achtergrond te tonen. Alleen beschikbaar in de Messy-template.', + 'Choose theme' => 'Kies een thema', + 'Color of the fullscreen gallery background.' => 'Achtergrondkleur van de fullscreen gallery.', + 'Column count for Mash-up section.' => 'Aantal kolommen in een Mash-up-sectie.', + 'Columns' => 'Kolommen', + 'Custom social media buttons' => 'Eigen social media buttons', + 'Displayed image will be half of the original size, full size will be used for hi-res displays.' => 'De getoonde afbeelding is half zo groot als het origineel; op schermen met hoge resolutie wordt het volledige formaat gebruikt.', + 'Enable navigation to page top' => 'Navigatie naar de bovenkant van de pagina inschakelen', + 'Enabled' => 'Ingeschakeld', + 'Enables fullscreen gallery slide numbers.' => 'Schakelt de nummering van de slides in de fullscreen gallery in.', + 'Enables gallery fullscreen mode for new entries.' => 'Schakelt de fullscreen-modus van de gallery in voor nieuwe entries.', + 'Enables responsive layout for mobile devices.' => 'Schakelt de responsive layout voor mobiele apparaten in.', + 'Entries are organised in columns. Column count can be changed under section settings.' => 'Entries staan in kolommen. Het aantal kolommen stel je in bij de instellingen van de sectie.', + 'Entry max width' => 'Maximale breedte van een entry', + 'Entry padding' => 'Binnenmarge van een entry', + 'Fill window' => 'Venster vullen', + 'Gallery fullscreen settings:' => 'Instellingen van de fullscreen gallery:', + 'Google Analytics measurement ID' => 'Google Analytics-metings-ID', + 'Google Tag Manager container ID' => 'Google Tag Manager-container-ID', + 'Google ownership verification tag. More info.' => '-tag voor de eigendomsverificatie van Google. Meer info.', + 'Grid layout' => 'Rasterlayout', + 'Gridlines color' => 'Kleur van de rasterlijnen', + 'Heading margin' => 'Marges van de heading', + 'Hide copyrights' => 'Copyright verbergen', + 'Hides Berta\'s copyrights' => 'Verbergt de copyrightvermelding van Berta', + 'How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.' => 'Zo vind je de metings-ID. Laat leeg als de Google Tag Manager-container-ID is ingevuld.', + 'How to get the container ID. Leave blank if Google Analytics measurement ID is filled.' => 'Zo vind je de container-ID. Laat leeg als de Google Analytics-metings-ID is ingevuld.', + 'Icon' => 'Icoon', + 'Javascript include' => 'Javascript invoegen', + 'Keep ratio' => 'Verhouding behouden', + 'Link' => 'Link', + 'Link address' => 'Linkadres', + 'Margin around menu.' => 'Marge rond het menu.', + 'Margin around page heading or logo.' => 'Marge rond de page heading of het logo.', + 'Mash-up columns' => 'Mash-up-kolommen', + 'Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Maximale grootte van een middelgrote afbeelding (zichtbaar als \'Medium images\' in de gallery-editor aan staat). Deze instellingen veranderen de originele afbeelding niet.', + 'Medium image height' => 'Hoogte van middelgrote afbeeldingen', + 'Medium image width' => 'Breedte van middelgrote afbeeldingen', + 'Menu margin' => 'Marges van het menu', + 'Name of your site. Visible in all sections.' => 'Naam van je site. Zichtbaar in alle secties.', + 'Opens in' => 'Opent in', + 'Page heading, menu items and section entries is horizontally centered to window.' => 'De page heading, de menu-items en de entries van de sectie worden horizontaal in het venster gecentreerd.', + 'Reset design' => 'Design resetten', + 'Reset design to default values.' => 'Zet het design terug naar de standaardwaarden.', + 'Responsive design' => 'Responsive design', + 'Responsive layout' => 'Responsive layout', + 'Sets whether layout should be responsive or not.' => 'Bepaalt of de layout responsive is.', + 'Show gridlines' => 'Rasterlijnen tonen', + 'Show page heading in first section?' => 'Page heading in de eerste sectie tonen?', + 'Shows navigation button at the bottom when page is scrolled.' => 'Toont een navigatieknop onderaan wanneer de pagina gescrold is.', + 'Slideshow loop mode' => 'Slideshow herhalen', + 'Social media buttons' => 'Social media buttons', + 'Social media links' => 'Social media links', + 'Texts' => 'Teksten', + 'Theme' => 'Thema', + 'Themes are ready made sites with predefined design settings.' => 'Thema\'s zijn kant-en-klare sites met vooraf ingestelde designinstellingen.', ]; diff --git a/engine/lang/pl.php b/engine/lang/pl.php index 38a0e9c34..c433a7ad2 100644 --- a/engine/lang/pl.php +++ b/engine/lang/pl.php @@ -6,57 +6,23 @@ // Translations from the top "admin" menu /* menu item */ - 'close this' => 'zamknąć', /* menu item */ - 'my site' => 'moja berta', /* menu item */ - 'sections' => 'działy', /* menu item */ - 'settings' => 'ustawienia', /* menu item */ - 'design' => 'wygląd', /* menu item */ - 'profile' => 'mój profil', /* menu item */ - 'log out' => 'wylogować się', - - // Translatins from login / logout window - - 'Logout ok. Please wait...' => 'Proszę, czekać...', - 'Log in' => 'Zalogować się', // Translatins from sections editor (sections.php) /* title */ - 'Sections' => 'Działy', /* column */ - 'Title as displayed in main menu' => 'Tytuł (w głównym menu)', /* column */ - 'Type' => 'Typ', /* column */ - 'Details' => 'Ustawienia', /* column */ - 'Is published?' => 'Publikowany?', /* column */ - 'Delete' => 'Kasować', /* button */ - 'create new section' => 'stworzyć nowy dział', /* button */ - 'delete' => 'kasować', - - 'What are sections?' => 'Co to są działy?', - 'sections_help_text' => 'Działy są podstawowe elementy strony i ich tytuły tworzą główne menu.', - 'What is the "external link"?' => 'Co to jest "external link" (link zewnētrzny)?', - 'external_link_help_text' => 'Elementem menu może być link zewnętrzny. Na przykład, adres e-mail mailto:sombeody@someplace.net) lub link do innej strony internetowej (na przykład, http://www.example.com)', - - // Translations from profile page (profile.php) - 'Profile' => 'Profil', - 'Old password' => 'Stare hasło', - 'New password' => 'Nowe hasło', - 'Retype new password' => 'Powtórz hasło', - 'Change password' => 'Zmienić hasło', - 'password_help_text' => 'Hasło musi mieć przynajmniej 6 symboli
              oraz zawierać jedynie duże/małe litery łaciny i/lub cyfry (A-Z, a-z, 0-9).', - '' => '', // Translations from settings page (settings.php) @@ -64,13 +30,8 @@ 'Settings' => 'Ustawienia', /* title */ 'Design' => 'Projekt', - 'These are settings for template' => 'Te są ustawienia projektu', - 'Some help with CSS values' => 'Objaśnienia wartości CSS', - 'Units' => 'Jednostki', - 'units_help_text' => 'Można wykorzystywać następujące jednostki:
              px – piksele
              em - 1 em = szerokość jednej litery M w odpowiedniej czcionce
              % - procenty od rozmiaru czcionki lub procenty od rozmiaru elementu kontenera', 'Margins' => 'Krawędzie', - 'margins_help_text' => 'Są 4 sposoby jak można ustawić rozmiar dla krawędzi (margins):
              1 wartość – ustala szerokość krawędzi górnej, prawej, dolnej i lewej w takich samych wartościach. Na przykład, 10px.
              2 wartość – Pierwsza cyfra ustala szerokość krawędzi górnej i dolnej, druga – szerokość krawędzi lewej i prawej. Na przykład: 0 5px.
              3 jednostki – pierwsza wartość ustala szerokość górnej krawędzi, druga – lewej i prawej krawędzi, i trzecia – szerokość dolnej krawędzi. Na przykład: 10px 0 20px.
              4 wartości - ustala szerokości dla wszystkich krawędzi w następującej kolejności: górna, krawędź prawa, dolna, krawędź lewa. Na przykład: 10px 0 20px 2px', // Translations from inc.settings.php @@ -83,7 +44,6 @@ 'Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.' => 'Projekty są "skórki" lub tematy, które zmieniają umiejscowienie elementów strony lub wygląd.', /* setting tab */ - 'Info' => 'Informacja', /* setting name */ 'Your name' => 'Twoje imię', /* help text */ @@ -95,7 +55,7 @@ /* setting name */ ' description' => ' opis', /* help text */ - 'Site description. It should not be longer than one or two sentences.' => 'Opis strony. Nie powinien być dłuższy niż 2 zdania.', + 'Short site description. It should not be longer than one or two sentences.' => 'Opis strony. Nie powinien być dłuższy niż 2 zdania.', /* setting tab */ 'Navigation' => 'Navigacja', @@ -104,7 +64,7 @@ /* help text */ 'Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.' => 'Wybierz "No", jeżeli chcesz, aby pierwszy dział nie byłby widoczny. Przejść do działu będzie można tylko otwierając stronę lub klikając na logo strony. Ustawienie to nie działa, jeżeli ten dział ma poddziały.', /* setting name */ - 'Show menu in first section' => 'Pokazać menu w pierwszym dziale?', + 'Show menu in first section?' => 'Pokazać menu w pierwszym dziale?', /* help text */ 'Choose "no" to hide the menu in first section.' => 'Wybierz "No", aby schować menu w pierwszym dziale.', /* setting name */ @@ -155,9 +115,7 @@ /* help text */ 'Set the default state of image number visibility in slideshow galleries.' => 'Ustanawia, czy domyślnie pokazywać numery obrazów w galeriach pokazów slajdów.', /* setting name */ - 'Video player' => 'Wygląd video player', /* help text */ - 'Choose between the two visually different players for your video files.' => 'Wybierz pomiędzy dwoma różnymi odtwarzaczami wideo.', /* setting tab */ 'Banners' => 'Banery', @@ -176,7 +134,6 @@ // Translations from entries view (and editing) - '

              Congratulations! You have successfully installed Berta.

              Now, before adding your content, you have to create a new section. Go to the sections page and do that!

              ' => '

              Gratulacje! Z powodzeniem zainstalowałeś swoją stronę internetową!

              Przed dodaniem informacji, stwórz nowy dział. Idź do tytuł

              ', 'create new entry here' => 'stworzyć nowy wpis', 'create new entry' => 'stworzyć nowy wpis', @@ -195,7 +152,7 @@ /* setting name */ 'Font style' => 'Styl czcionki', /* setting name */ - 'Font variant' => 'rodzaj czcionki', + 'Font variant' => 'Rodzaj czcionki', /* setting name */ 'Line height' => 'Wysokość wiersza', /* help text */ @@ -268,7 +225,6 @@ /* setting name */ 'Header image' => 'Obraz nagłówka strony', /* help text */ - 'Picture to use instead of text.' => 'Obraz, który będzie wykorzystany zamiast tekstu w nagłówku strony.', /* help text */ 'How far the heading is from other elements in page.' => 'Jak daleko nagłówek strony znajduje się od innych elementów.', /* help text */ @@ -305,17 +261,11 @@ /* help text */ 'Margin around gallery block' => 'Krawędzie wokół bloku galerii', /* setting name */ - 'Display tags by each entry' => 'Pokazywać Tagi przy każdym wpisie', /* help text */ - 'This determines whether people will see tags you set for each entry. Regardless of this settting, tags still will make up the main menu.' => 'To ustala, czy pokazywać Tagi przy każdym wpisie. Niezależnie od ustawień, Tagi będą widoczne w głównym menu.', /* setting name */ - 'Date format in entries' => 'Format daty dla wpisów', /* setting name */ - 'Date separator' => 'Oddzielać daty', /* help text */ - 'Separator symbol that divides year, month and day' => 'Symbol, który oddziela rok, miesiąc i dzień', /* setting name */ - 'Time separator' => 'Symbol oddzielający cyfry godziny', /* setting tab */ 'Entry heading' => 'Tytuł wpisu', @@ -355,7 +305,6 @@ /* setting name */ 'Left margin' => 'Odległość od lewej strony', /* help text */ - 'How far the sidebar is from the left side of the screen.' => 'Jak daleko pasek boczny znajduje się od lewej krawędzi strony?', /* setting name */ 'Top padding' => 'Odległość od góry', /* help text */ @@ -444,71 +393,90 @@ /* help text */ 'IMPORTANT! This must be set as percentage. i.e. 60%' => 'UWAGA! Ten parametr musi być wprowadzony jako procenty. Na przykład, 60%', - 'googleFont_description' => 'Wpisz nazwę czcionki google. Obejrzyj, jakie czcionki są dostępne: Google Fonts. Jeżeli wybrana jest czcionka google, wtedy czcionki systemu nie będą brane pod uwagę. Na przykład: Marvel, Marvel:700italic lub Josefin Slab:700italic', + 'google_font_description' => 'Wpisz nazwę czcionki google. Obejrzyj, jakie czcionki są dostępne: Google Fonts. Jeżeli wybrana jest czcionka google, wtedy czcionki systemu nie będą brane pod uwagę. Na przykład: Marvel, Marvel:700italic lub Josefin Slab:700italic', 'Menu position' => 'Pozycja menu', 'Positon X' => 'Pozycja X', 'Positon Y' => 'Pozycja Y', - 'description_tagsMenu_x' => 'Pozycja X w menu podrzędnym w pikselach (np.: 50px)', - 'description_tagsMenu_y' => 'Pozycja Y w menu podrzędnym w pikselach (np.: 50px)', - 'description_menu_position' => 'Pozycjonowanie menu', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'Link banera. Przed adresem nie zapomnij dodać https:// (piem.: https://www.berta.me)', - 'description_language' => 'Choose language of interface.', + 'tags_menu_x_description' => 'Pozycja X w menu podrzędnym w pikselach (np.: 50px)', + 'tags_menu_y_description' => 'Pozycja Y w menu podrzędnym w pikselach (np.: 50px)', + 'menu_position_description' => 'Pozycjonowanie menu', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'Link banera. Przed adresem nie zapomnij dodać https:// (piem.: https://www.berta.me)', + 'language_description' => 'Choose language of interface.', 'Heading position' => 'Pozycja nagłówka', - 'description_heading_position' => 'Pozycja nagłówka, niezmienna lub zmienna', - 'description_submenu_alwaysopen' => 'Rozdziały zawsze są otwarte.', + 'heading_position_description' => 'Pozycja nagłówka, niezmienna lub zmienna', + 'tags_menu_always_open_description' => 'Rozdziały zawsze są otwarte.', 'Submenu is allways open' => 'Menu podrzędne zawsze otwarte', 'Submenu is hidden' => 'Menu podrzędne schowane', - 'javascript_include' => 'Kod javascript, który będzie dodany przed elementem </body>. Dowiedz się więcej jak Jak zawierać kod JavaScript.', - 'description_custom_css' => 'Wprowadź swój kod CSS tu. Definicje CSS, które istniały wcześniej, zostaną przepisane. Do uzyskania więcej informacji obejrzyj WIKI.', - - // Videos panel & newsticker - 'To enable Berta\'s tutorial videos, your computer needs to be connected to the internet!
              When the internet access is enabled, sign out of engine panel and log in again to view the videos.' => 'Aby obejrzeć pouczenie Berty, komputer musi posiadać podłączenie internetowe.
              Kiedy komputer jest podłączony do internetu, wyloguj się z panela engine i zaloguj się ponownie.', - 'Show this window on startup' => 'Zawsze pokazywać to okno po zalogowaniu się', - 'Close' => 'Zamknąć', - - 'To enable Berta\'s news ticker, your computer needs to be connected to the internet!' => 'Aby zobaczyć nowości Berta, niezbędne jest połączenie internetowe do komputera!', + 'js_include_description' => 'Kod javascript, który będzie dodany przed elementem </body>. Dowiedz się więcej jak Jak zawierać kod JavaScript.', + 'custom_css_description' => 'Wprowadź swój kod CSS tu. Definicje CSS, które istniały wcześniej, zostaną przepisane. Do uzyskania więcej informacji obejrzyj WIKI.', // shop translations - 'Shop' => 'Sklep', - 'shop' => 'sklep', - 'Inventory' => 'Magazyn', - 'Regional costs' => 'Koszty regionalne', - 'Item name' => 'Nazwa jednostki', - 'Price' => 'Cena', - 'price' => 'cena', - 'In stock' => 'W magazynie', - 'Reservation' => 'Zarezerwowane', - 'if weight is less than' => 'jeżeli waga jest mniejsza niż', - 'weight' => 'waga', - 'then price is' => 'wtedy cena jest', - 'region name' => 'nazwa regionu', - 'vat' => 'vat', - '+ add new costs condition' => '+ dodać nowe kryterium kosztów', - '+ add new region' => '+ dodać nowy region', - 'Save' => 'Zapisać', 'Configuration values' => 'Wartości konfiguracji', '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.', + 'shop_currency_description' => 'Skrót lub symbol waluty wyświetlany przy każdej cenie na stronie.', + 'Order email subject' => 'Temat e-maila z zamówieniem', + 'shop_order_email_subject_description' => 'Temat e-maila z zamówieniem wysyłanego do klienta.', + 'Required.' => 'Wymagane.', '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.', - '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', - 'Out of stock text' => 'Tekst uwagi "Nie ma w magazynie".', - 'Email' => 'e-mail', - 'Email where send order data' => 'e-mail, na który wysyłać dane zamówienia.', + 'shop_seller_details_description' => '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' => 'Dodaj do koszyka', + 'shop_add_to_basket_description' => 'Tekst przycisku, który dodaje produkt do koszyka.', + 'Out of stock' => 'Brak w magazynie', + 'shop_out_of_stock_description' => 'Komunikat wyświetlany, gdy produkt jest wyprzedany.', + 'Email' => 'E-mail', + 'shop_email_description' => 'Twój adres e-mail, na który sklep wysyła każde nowe zamówienie.', 'Payment method' => 'Metoda płatności', - 'Payment method.' => 'Metoda płatności.', 'Promo code' => 'Kod rabatowy', - 'If Yes - promo code entry will be displayed.' => 'Jeżeli pole jest wypełnione, kupujący będzie mógł wpisać kod rabatowy.', 'Promo code discount' => 'Wartość rabatu', - 'Discount in precents when user enters valid promo code' => 'Wartość rabatu w procentach na towary, jeżeli kupujący wprowadził poprawny kod.', + 'shop_promo_code_discount_description' => 'Rabat w procentach dla klientów, którzy wpiszą kod rabatowy.', 'Weight units' => 'Jednostka wagi', - 'Weight units (g, oz) used for postage cost calculation.' => 'Jednostka wagi (g, oz), która jest używana, obliczając koszty wysyłki pocztowej.', + 'shop_weight_unit_description' => 'Jednostka wagi produktów, np. g lub oz. Koszty wysyłki są obliczane na podstawie wagi.', 'Shop entry width' => 'Szerokość wpisu sklepu', + 'PDF invoices' => 'Faktury PDF', + 'shop_invoices_description' => 'Wybierz "Yes", aby dołączać fakturę PDF do e-maila z zamówieniem dla klienta i pobierać faktury w zakładce "Orders". Faktury wymagają powyższych danych sprzedawcy, z danymi Twojej firmy i rachunku bankowego: bez nich faktura nie zostanie utworzona.', + 'Basket expires' => 'Ważność koszyka', + 'shop_basket_expires_minutes_description' => 'Liczba minut, przez które produkty w koszyku pozostają zarezerwowane po ostatniej czynności klienta w sklepie.', + 'Added to basket' => 'Dodano do koszyka', + 'shop_added_to_basket_description' => 'Komunikat wyświetlany po dodaniu produktu do koszyka.', + 'PayPal and debit or credit card' => 'PayPal i karta debetowa lub kredytowa', + 'Bank transfer' => 'Przelew bankowy', + 'PayPal, debit or credit card and bank transfer' => 'PayPal, karta debetowa lub kredytowa i przelew bankowy', + 'shop_payment_method_description' => 'Metody płatności oferowane przy składaniu zamówienia. Płatności PayPal i kartą wymagają też poniższych danych logowania PayPal.', + 'shop_promo_code_description' => 'Kod, który daje klientom rabat. Gdy wypełniony jest ten kod i poniższy rabat, koszyk prosi o kod rabatowy.', + 'Terms of service link' => 'Link do regulaminu', + 'shop_terms_link_description' => 'Adres strony z Twoim regulaminem. Gdy jest wypełniony, koszyk pokazuje link do niego przed złożeniem zamówienia.', + 'PayPal' => 'PayPal', + 'shop_paypal_description' => 'Aby przyjmować płatności PayPal oraz kartą debetową lub kredytową, połącz swoje firmowe konto PayPal. Wypełnij jeden zestaw danych logowania poniżej, nie oba: dane aplikacji REST (do nowej konfiguracji) lub dane Classic API (starsze sklepy już z nimi skonfigurowane). Gdy wypełnione są oba, używane są dane REST.', + 'Currency' => 'Waluta', + 'Required for PayPal payments.' => 'Wymagane do płatności PayPal.', + 'shop_paypal_currency_code_description' => 'Trzyliterowy kod waluty, w której płacą Twoi klienci, np. EUR lub USD. Użyj waluty cen w Twoim sklepie: gdy pole jest puste, PayPal pobiera te same kwoty w dolarach amerykańskich, bez przeliczania. Waluty obsługiwane przez PayPal', + 'Test mode (sandbox)' => 'Tryb testowy (sandbox)', + 'shop_paypal_sandbox_description' => 'Gdy jest włączony, płatności trafiają do środowiska testowego PayPal (sandbox), w którym nie są przesyłane prawdziwe pieniądze, więc możesz wypróbować składanie zamówienia. Środowisko testowe wymaga danych logowania sandbox, innych niż prawdziwe. Przed rozpoczęciem sprzedaży wyłącz tryb testowy i wpisz prawdziwe dane logowania.', + 'PayPal REST app credentials (recommended)' => 'Dane aplikacji REST PayPal (zalecane)', + 'shop_paypal_rest_description' => 'Gdzie je znaleźć: zaloguj się do PayPal Developer Dashboard swoim firmowym kontem PayPal i otwórz Apps & Credentials. Otwórz tam swoją aplikację lub kliknij Create App, aby ją utworzyć, i skopiuj jej Client ID oraz Secret do pól poniżej. W trybie testowym skopiuj je z karty Sandbox zamiast Live. Przewodnik PayPal', + 'Client ID' => 'Client ID', + 'shop_paypal_client_id_description' => 'Długi kod z liter i cyfr, widoczny na stronie Twojej aplikacji.', + 'Secret' => 'Secret', + 'shop_paypal_client_secret_description' => 'Widoczny pod Client ID na tej samej stronie; odkryj go, aby go skopiować. Zachowaj go w tajemnicy.', + 'PayPal Classic API credentials (older shops)' => 'Dane Classic API PayPal (starsze sklepy)', + 'shop_paypal_classic_description' => 'Tylko dla sklepów już z nimi skonfigurowanych: zostaw te pola puste, gdy używasz powyższych danych aplikacji REST. Gdzie je znaleźć: zaloguj się na paypal.com, przejdź do Account Settings › Account access, kliknij Update obok API access, a potem Manage API credentials w sekcji NVP/SOAP API integration (Classic). W trybie testowym zrób to samo na sandbox.paypal.com z firmowym kontem sandbox. Przewodnik PayPal', + 'API username' => 'Nazwa użytkownika API', + 'shop_paypal_user_name_description' => 'Wygląda tak: yourname_api1.example.com.', + 'API password' => 'Hasło API', + 'API signature' => 'Podpis API', + 'shop_paypal_signature_description' => 'Długi ciąg liter i cyfr.', + 'Shopping cart image' => 'Obrazek koszyka', + 'shop_cart_image_description' => 'Obrazek wyświetlany zamiast tekstu linku do koszyka. Zalecany rozmiar: do 50 × 50 pikseli.', + 'shop_price_item_font_color_description' => 'Kolor tekstu ceny wyświetlanej przy każdym produkcie.', + 'shop_price_item_font_family_description' => 'Czcionka ceny produktu. Gdy poniżej podana jest czcionka Google, używana jest ona.', + 'Google web fonts' => 'Czcionki internetowe Google', + 'shop_price_item_font_size_description' => 'Rozmiar ceny produktu, np. 12px lub 1.2em.', + 'shop_price_item_font_weight_description' => 'Wyświetla cenę produktu zwykłym lub pogrubionym tekstem.', + 'shop_price_item_font_style_description' => 'Wyświetla cenę produktu zwykłym tekstem lub kursywą.', + 'shop_price_item_font_variant_description' => 'small-caps wyświetla litery w cenie, np. walutę, jako kapitaliki.', + 'shop_price_item_line_height_description' => 'Wysokość wiersza tekstu ceny produktu, np. 14px, 1.2em lub normal.', + 'shop_entry_width_description' => 'Szerokość wpisów produktów w pikselach. Używana tylko przez szablon Messy, gdy jego układ nie jest responsywny. Własna szerokość wpisu ma pierwszeństwo. Zostaw puste, aby użyć domyślnej szerokości.', 'section_type' => 'Type', 'section_type_tip' => 'Defines the layout and functionality of the section.', 'select_on' => 'On', @@ -516,4 +484,83 @@ 'shuffle_marked' => 'Shuffle marked entries and galleries', 'entries_limit' => 'Entries limit', 'entries_limit_tip' => 'How many entries to show in this section', + + // Settings and templates + 'Additional text' => 'Dodatkowy tekst', + 'Background video (embed)' => 'Wideo w tle (osadzone)', + 'Background video ratio' => 'Proporcje wideo w tle', + 'Button color' => 'Kolor przycisku', + 'Centered content width' => 'Szerokość wyśrodkowanej treści', + 'Centered contents' => 'Wyśrodkowana treść', + 'Centered layout' => 'Wyśrodkowany układ', + 'Centering guides color tone' => 'Odcień linii pomocniczych wyśrodkowania', + 'Choose "no" to hide page heading in first section.' => 'Wybierz "No", aby ukryć tytuł strony w pierwszym dziale.', + 'Choose "yes" to show gridlines in background. Available only in Messy template.' => 'Wybierz "Yes", aby pokazać linie siatki w tle. Dostępne tylko w szablonie Messy.', + 'Choose theme' => 'Wybierz motyw', + 'Color of the fullscreen gallery background.' => 'Kolor tła galerii pełnoekranowej.', + 'Color tone for centering guides (dark for bright background colors, bright for dark background colors).' => 'Odcień linii pomocniczych wyśrodkowania (ciemny dla jasnych kolorów tła, jasny dla ciemnych kolorów tła).', + 'Column count for Mash-up section.' => 'Liczba kolumn w dziale Mash-up.', + 'Columns' => 'Kolumny', + 'Content width if layout is centered.' => 'Szerokość treści, gdy układ jest wyśrodkowany.', + 'Custom CSS' => 'Własny CSS', + 'Custom social media buttons' => 'Własne przyciski mediów społecznościowych', + 'Displayed image will be half of the original size, full size will be used for hi-res displays.' => 'Wyświetlany obraz ma połowę oryginalnego rozmiaru, pełny rozmiar jest używany na ekranach o wysokiej rozdzielczości.', + 'Enable navigation to page top' => 'Włącz przycisk powrotu na górę strony', + 'Enabled' => 'Włączone', + 'Enables fullscreen gallery slide numbers.' => 'Włącza numery slajdów w galerii pełnoekranowej.', + 'Enables gallery fullscreen mode for new entries.' => 'Włącza tryb pełnoekranowy galerii dla nowych wpisów.', + 'Enables responsive layout for mobile devices.' => 'Włącza układ responsywny dla urządzeń mobilnych.', + 'Entries are organised in columns. Column count can be changed under section settings.' => 'Wpisy są ułożone w kolumnach. Liczbę kolumn można zmienić w ustawieniach działu.', + 'Entry max width' => 'Maksymalna szerokość wpisu', + 'Entry padding' => 'Wewnętrzny odstęp wpisu', + 'Fill window' => 'Wypełnij okno', + 'Gallery fullscreen settings:' => 'Ustawienia galerii pełnoekranowej:', + 'Google Analytics measurement ID' => 'Identyfikator pomiaru Google Analytics', + 'Google Tag Manager container ID' => 'Identyfikator kontenera Google Tag Manager', + 'Google ownership verification tag. More info.' => 'Tag weryfikacji własności Google. Więcej informacji.', + 'Google site verification tag' => 'Tag weryfikacji witryny Google', + 'Grid layout' => 'Układ siatki', + 'Gridlines color' => 'Kolor linii siatki', + 'HTML code' => 'Kod HTML', + 'Heading margin' => 'Krawędzie tytułu', + 'Hide copyrights' => 'Ukryj prawa autorskie', + 'Hides Berta\'s copyrights' => 'Ukrywa informację o prawach autorskich Berty', + 'How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.' => 'Odległość paska bocznego od lewej krawędzi ekranu. Ignorowana, gdy włączony jest wyśrodkowany układ.', + 'How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.' => 'Jak uzyskać identyfikator pomiaru. Zostaw puste, jeśli wypełniony jest identyfikator kontenera Google Tag Manager.', + 'How to get the container ID. Leave blank if Google Analytics measurement ID is filled.' => 'Jak uzyskać identyfikator kontenera. Zostaw puste, jeśli wypełniony jest identyfikator pomiaru Google Analytics.', + 'Icon' => 'Ikona', + 'Javascript code' => 'Kod Javascript', + 'Javascript include' => 'Dołączony Javascript', + 'Keep ratio' => 'Zachowaj proporcje', + 'Link' => 'Link', + 'Link address' => 'Adres linku', + 'Location' => 'Położenie', + 'Location of social media buttons. Content of additional text or additional footer will be replaced.' => 'Położenie przycisków mediów społecznościowych. Treść dodatkowego tekstu lub dodatkowej stopki zostanie zastąpiona.', + 'Margin around menu.' => 'Odstęp wokół menu.', + 'Margin around page heading or logo.' => 'Odstęp wokół tytułu strony lub logo.', + 'Mash-up columns' => 'Kolumny Mash-up', + 'Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Maksymalny rozmiar średniego obrazu (widoczny, gdy w edytorze galerii włączone są „Medium images”). Te ustawienia nie zmieniają oryginalnego obrazu.', + 'Medium image height' => 'Wysokość średniego obrazu', + 'Medium image width' => 'Szerokość średniego obrazu', + 'Menu margin' => 'Krawędzie menu', + 'Name of your site. Visible in all sections.' => 'Nazwa Twojej strony. Widoczna we wszystkich działach.', + 'Opens in' => 'Otwiera się w', + 'Page heading, menu items and section entries is horizontally centered to window.' => 'Tytuł strony, pozycje menu i wpisy działu są wyśrodkowane w poziomie w oknie.', + 'Paste or write your HTML code here.' => 'Wklej lub wpisz tutaj swój kod HTML.', + 'Paste or write your Javascript code here.' => 'Wklej lub wpisz tutaj swój kod Javascript.', + 'Reset design' => 'Przywróć projekt', + 'Reset design to default values.' => 'Przywraca domyślne wartości projektu.', + 'Responsive design' => 'Projekt responsywny', + 'Responsive layout' => 'Układ responsywny', + 'Sets whether layout should be centered or not.' => 'Określa, czy układ ma być wyśrodkowany.', + 'Sets whether layout should be responsive or not.' => 'Określa, czy układ ma być responsywny.', + 'Show gridlines' => 'Pokaż linie siatki', + 'Show page heading in first section?' => 'Pokazać tytuł strony w pierwszym dziale?', + 'Shows navigation button at the bottom when page is scrolled.' => 'Pokazuje przycisk nawigacji na dole, gdy strona jest przewinięta.', + 'Slideshow loop mode' => 'Pętla pokazu slajdów', + 'Social media buttons' => 'Przyciski mediów społecznościowych', + 'Social media links' => 'Linki do mediów społecznościowych', + 'Texts' => 'Teksty', + 'Theme' => 'Motyw', + 'Themes are ready made sites with predefined design settings.' => 'Motywy to gotowe strony z ustawieniami projektu.', ]; diff --git a/engine/lang/ru.php b/engine/lang/ru.php index 978dfc479..2014dc950 100644 --- a/engine/lang/ru.php +++ b/engine/lang/ru.php @@ -6,57 +6,23 @@ // Translations from the top "admin" menu /* menu item */ - 'close this' => 'закрыть', /* menu item */ - 'my site' => 'мой сайт', /* menu item */ - 'sections' => 'разделы', /* menu item */ - 'settings' => 'настройки', /* menu item */ - 'design' => 'дизайн', /* menu item */ - 'profile' => 'профиль', /* menu item */ - 'log out' => 'выйти', - - // Translatins from login / logout window - - 'Logout ok. Please wait...' => 'Пожалуйста, подождите...', - 'Log in' => 'Войти', // Translatins from sections editor (sections.php) /* title */ - 'Sections' => 'Разделы', /* column */ - 'Title as displayed in main menu' => 'Название, отображаемое в главном меню', /* column */ - 'Type' => 'Вид', /* column */ - 'Details' => 'Настройки', /* column */ - 'Is published?' => 'Опубликован?', /* column */ - 'Delete' => 'Удалить', /* button */ - 'create new section' => 'создать новый раздел', /* button */ - 'delete' => 'удалить', - - 'What are sections?' => 'Что такое разделы?', - 'sections_help_text' => 'Разделы – это основные элементы сайта и их названия формируют главное меню.', - 'What is the "external link"?' => 'Что такое "внешняя ссылка"?', - 'external_link_help_text' => 'Элемент меню может быть внешней ссылкой. (например, адрес электронной почты mailto:sombeody@someplace.net) или ссылка на другой сайт (например, http://www.example.com)', - - // Translations from profile page (profile.php) - 'Profile' => 'Профиль', - 'Old password' => 'Старый пароль', - 'New password' => 'Новый пароль', - 'Retype new password' => 'Новый пароль еще раз', - 'Change password' => 'Сменить пароль', - 'password_help_text' => 'Пароль должен состоять минимум из 6 символов
              и содержать только большие/маленькие латинские буквы и/или цифры(A-Z, a-z, 0-9).', - '' => '', // Translations from settings page (settings.php) @@ -64,13 +30,8 @@ 'Settings' => 'Настройки', /* title */ 'Design' => 'Шаблон', - 'These are settings for template' => 'Это настройки шаблона', - 'Some help with CSS values' => 'Объяснение значений CSS', - 'Units' => 'Единицы', - 'units_help_text' => 'Можно использовать следующие единицы:
              px – пиксели
              em - 1 em = ширина одной буквы M в определенном шрифте
              % - проценты размера шрифта или проценты размера элемента контейнера', 'Margins' => 'Поля отступа', - 'margins_help_text' => 'Есть 4 способа, как можно вводить размер отступов:
              1 значение – задает размер верхнего, правого, нижнего и левого отступа одного размера. Например, 10px.
              2 значения – первая цифра задает размер верхнего и нижнего отступа, вторая – размер правого и левого отступа. Например: 0 5px.
              3 значения – первая цифра определяет размер верхнего отступа, вторая – правого и левого отступа, а третья – размер нижнего отступа. Например: 10px 0 20px.
              4 значения - определяет размеры всех отступов в следующем порядке: верх, правая сторона, низ, левая сторона. Например: 10px 0 20px 2px', // Translations from inc.settings.php @@ -80,10 +41,8 @@ /* setting tab & setting name */ 'Template' => 'Шаблон', /* help text */ - 'Шаблоны – это "скины" или темы вашего сайта. Вы можете выбрать один шаблон из установленных в вашей папке шаблонов. Чтобы добавить новый шаблон в этот список, загрузите его в папку шаблонов через FTP клиент.' => 'Шаблоны – это "скины" или темы, которые меняют расположение и вид элементов сайта.', /* setting tab */ - 'Info' => 'Информация', /* setting name */ 'Your name' => 'Ваше имя', /* help text */ @@ -95,7 +54,7 @@ /* setting name */ ' description' => ' описание', /* help text */ - 'Site description. It should not be longer than one or two sentences.' => 'Описание сайта. Описание не должно превышать одного-двух предложений.', + 'Short site description. It should not be longer than one or two sentences.' => 'Описание сайта. Описание не должно превышать одного-двух предложений.', /* setting tab */ 'Navigation' => 'Навигация', @@ -155,9 +114,7 @@ /* help text */ 'Set the default state of image number visibility in slideshow galleries.' => 'Отображает нумерацию изображений в галереях по умолчанию.', /* setting name */ - 'Video player' => 'Вид видео проигрывателя', /* help text */ - 'Choose between the two visually different players for your video files.' => 'Выберите между двумя предлагаемыми видами видео проигрывателей.', /* setting tab */ 'Banners' => 'Баннеры', @@ -176,7 +133,6 @@ // Translations from entries view (and editing) - '

              Congratulations! You have successfully installed Berta.

              Now, before adding your content, you have to create a new section. Go to the sections page and do that!

              ' => '

              Поздравляем! Вы успешно создали свой web сайт!

              Прежде чем добавить содержание, создайте новый раздел. Перейти к разделам

              ', 'create new entry here' => 'создать новую запись здесь', 'create new entry' => 'создать новую запись', @@ -268,7 +224,6 @@ /* setting name */ 'Header image' => 'Изображение шапки сайта', /* help text */ - 'Picture to use instead of text.' => 'Изображение, которое будет использовано вместо заголовка в шапке страницы.', /* help text */ 'How far the heading is from other elements in page.' => 'Расстояние между шапкой страницы и другими элементами.', /* help text */ @@ -344,7 +299,6 @@ /* setting name */ 'Left margin' => 'Отступ слева', /* help text */ - 'How far the sidebar is from the left side of the screen.' => 'Определяет расстояние боковой панели от левой стороны экрана.', /* setting name */ 'Top padding' => 'Расстояние сверху', /* help text */ @@ -433,30 +387,26 @@ /* help text */ 'IMPORTANT! This must be set as percentage. i.e. 60%' => 'ВАЖНО! Данный параметр вводится как процентное соотношение. Например, 60%', - 'googleFont_description' => 'Укажите название Google шрифта. Ознакомтесь, какие шрифты доступны: Google Fonts. Если выбран Google шрифт, системные шрифты не будут приниматься во внимание. Например: Marvel, Marvel:700italic или Josefin Slab:700italic', + 'google_font_description' => 'Укажите название Google шрифта. Ознакомьтесь, какие шрифты доступны: Google Fonts. Если выбран Google шрифт, системные шрифты не будут приниматься во внимание. Например: Marvel, Marvel:700italic или Josefin Slab:700italic', 'Menu position' => 'Положение меню', 'Positon X' => 'Положение X', 'Positon Y' => 'Положение Y', - 'description_tagsMenu_x' => 'Положение подменю на оси X в пикселях (например: 50px)', - 'description_tagsMenu_y' => 'Положение подменю на оси Y в пикселях (например: 50px)', - 'description_menu_position' => 'Позиционирование меню', - 'description_banner' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', - 'description_banner_link' => 'Ссылка баннера. Добавьте https:// перед адресом ссылки (например: https://www.berta.me)', - 'description_language' => 'Выберите язык интерфейса berta. Обновите страницу, чтобы активировать изменения.', + 'tags_menu_x_description' => 'Положение подменю на оси X в пикселях (например: 50px)', + 'tags_menu_y_description' => 'Положение подменю на оси Y в пикселях (например: 50px)', + 'menu_position_description' => 'Позиционирование меню', + 'banner_image_description' => 'Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays.', + 'banner_link_description' => 'Ссылка баннера. Добавьте https:// перед адресом ссылки (например: https://www.berta.me)', + 'language_description' => 'Выберите язык интерфейса berta. Обновите страницу, чтобы активировать изменения.', 'Heading position' => 'Положение заголовка', - 'description_heading_position' => 'Положение шапки, фиксированное или нефиксированное ', - 'description_submenu_alwaysopen' => 'Подменю всегда открыто', + 'heading_position_description' => 'Положение шапки, фиксированное или нефиксированное ', + 'tags_menu_always_open_description' => 'Подменю всегда открыто', 'Submenu is allways open' => 'Подменю всегда открыто', 'Submenu is hidden' => 'Спрятать подменю', - 'javascript_include' => 'Код Javascript, который будет включен прямо перед элементом body. Узнайте больше о том, как добавить код JavaScript.', - 'description_custom_css' => 'Добавьте свой код CSS здесь. Ранее заданные дефиниции CSS будут обновлены. Больше информации найдете в нашем Справочнике.', + 'js_include_description' => 'Код Javascript, который будет включен прямо перед элементом body. Узнайте больше о том, как добавить код JavaScript.', + 'custom_css_description' => 'Добавьте свой код CSS здесь. Ранее заданные дефиниции CSS будут обновлены. Больше информации найдете в нашем Справочнике.', // Videos panel & newsticker - 'To enable Berta\'s tutorial videos, your computer needs to be connected to the internet!
              When the internet access is enabled, sign out of engine panel and log in again to view the videos.' => 'Чтобы просмотреть обучающее видео о Berta, вашему компьютеру необходим доступ в интернет.
              Когда подключите компьютер к интернету, выйдите и снова зайдите в панель управления сайтом, чтобы посмотреть видео.', - 'Show this window on startup' => 'Всегда показывать данное окно при входе в систему', - 'Close' => 'Закрыть', - 'To enable Berta\'s news ticker, your computer needs to be connected to the internet!' => 'Чтобы читать новости Berta, вашему компьютеру необходим доступ в интернет!', 'section_type' => 'Type', 'section_type_tip' => 'Defines the layout and functionality of the section.', 'select_on' => 'On', @@ -464,4 +414,151 @@ 'shuffle_marked' => 'Shuffle marked entries and galleries', 'entries_limit' => 'Entries limit', 'entries_limit_tip' => 'How many entries to show in this section', + + // shop translations + 'Configuration values' => 'Значения конфигурации', + 'Shop currency' => 'Валюта магазина', + 'shop_currency_description' => 'Сокращение или символ валюты, который показывается рядом с каждой ценой на сайте.', + 'Order email subject' => 'Тема письма о заказе', + 'shop_order_email_subject_description' => 'Тема письма о заказе, которое отправляется покупателю.', + 'Seller details' => 'Реквизиты продавца', + 'shop_seller_details_description' => 'Реквизиты вашей компании и банковского счёта, которые включаются в письма о заказе. Покупатели, оплачивающие банковским переводом, платят на указанный здесь счёт.', + 'Required.' => 'Обязательно.', + 'PDF invoices' => 'PDF-счета', + 'shop_invoices_description' => 'Выберите «Yes», чтобы прикреплять PDF-счёт к письму о заказе для покупателя и скачивать счета в разделе «Orders». Для счетов нужны указанные выше реквизиты продавца с данными вашей компании и банковского счёта: без них счёт не создаётся.', + 'Basket expires' => 'Срок действия корзины', + 'shop_basket_expires_minutes_description' => 'Сколько минут товары в корзине остаются зарезервированными после последнего действия покупателя в магазине.', + 'Add to basket' => 'Добавить в корзину', + 'shop_add_to_basket_description' => 'Текст кнопки, которая добавляет товар в корзину.', + 'Added to basket' => 'Добавлено в корзину', + 'shop_added_to_basket_description' => 'Сообщение, которое показывается после добавления товара в корзину.', + 'Out of stock' => 'Нет в наличии', + 'shop_out_of_stock_description' => 'Сообщение, которое показывается, когда товар распродан.', + 'Email' => 'Электронная почта', + 'shop_email_description' => 'Ваш адрес электронной почты, на который магазин отправляет каждый новый заказ.', + 'PayPal and debit or credit card' => 'PayPal и дебетовая или кредитная карта', + 'Bank transfer' => 'Банковский перевод', + 'PayPal, debit or credit card and bank transfer' => 'PayPal, дебетовая или кредитная карта и банковский перевод', + 'Payment method' => 'Способ оплаты', + 'shop_payment_method_description' => 'Способы оплаты, предлагаемые при оформлении заказа. Для оплаты через PayPal и картой также нужны данные PayPal ниже.', + 'Promo code' => 'Промокод', + 'shop_promo_code_description' => 'Код, который даёт покупателям скидку. Если заполнены и он, и скидка ниже, корзина запрашивает промокод.', + 'Promo code discount' => 'Скидка по промокоду', + 'shop_promo_code_discount_description' => 'Скидка в процентах для покупателей, которые вводят промокод.', + 'Terms of service link' => 'Ссылка на условия использования', + 'shop_terms_link_description' => 'Адрес вашей страницы с условиями использования. Если он указан, корзина показывает ссылку на неё перед оформлением заказа.', + 'Weight units' => 'Единицы веса', + 'shop_weight_unit_description' => 'Единица веса товаров, например g или oz. Стоимость доставки рассчитывается по весу.', + 'PayPal' => 'PayPal', + 'shop_paypal_description' => 'Чтобы принимать платежи через PayPal и дебетовыми или кредитными картами, подключите свой бизнес-аккаунт PayPal. Заполните один набор учётных данных ниже, а не оба: учётные данные REST-приложения (для новой настройки) или учётные данные Classic API (для старых магазинов, уже настроенных с ними). Если заполнены оба, используются данные REST.', + 'Currency' => 'Валюта', + 'Required for PayPal payments.' => 'Обязательно для платежей через PayPal.', + 'shop_paypal_currency_code_description' => 'Трёхбуквенный код валюты, в которой платят ваши покупатели, например EUR или USD. Укажите валюту цен вашего магазина: если поле пустое, PayPal списывает те же суммы в долларах США, не конвертируя их. Валюты, которые поддерживает PayPal', + 'Test mode (sandbox)' => 'Тестовый режим (sandbox)', + 'shop_paypal_sandbox_description' => 'Когда включено, платежи идут в тестовую среду PayPal (sandbox), где реальные деньги не переводятся, поэтому можно опробовать оформление заказа. Для тестовой среды нужны учётные данные sandbox, которые отличаются от рабочих. Перед началом продаж выключите тестовый режим и введите рабочие учётные данные.', + 'PayPal REST app credentials (recommended)' => 'Учётные данные REST-приложения PayPal (рекомендуется)', + 'shop_paypal_rest_description' => 'Где их найти: войдите в PayPal Developer Dashboard со своим бизнес-аккаунтом PayPal и откройте Apps & Credentials. Откройте там своё приложение или нажмите Create App, чтобы создать его, и скопируйте его Client ID и Secret в поля ниже. Для тестового режима копируйте их со вкладки Sandbox, а не Live. Руководство PayPal', + 'Client ID' => 'Client ID', + 'shop_paypal_client_id_description' => 'Длинный код из букв и цифр, указанный на странице вашего приложения.', + 'Secret' => 'Secret', + 'shop_paypal_client_secret_description' => 'Указан под Client ID на той же странице; откройте его, чтобы скопировать. Храните его в тайне.', + 'PayPal Classic API credentials (older shops)' => 'Учётные данные Classic API PayPal (для старых магазинов)', + 'shop_paypal_classic_description' => 'Только для магазинов, уже настроенных с ними: оставьте эти поля пустыми, если используете учётные данные REST-приложения выше. Где их найти: войдите на paypal.com, перейдите в Account Settings › Account access, нажмите Update рядом с API access, затем Manage API credentials в разделе NVP/SOAP API integration (Classic). Для тестового режима сделайте то же самое на sandbox.paypal.com с бизнес-аккаунтом sandbox. Руководство PayPal', + 'API username' => 'Имя пользователя API', + 'shop_paypal_user_name_description' => 'Выглядит так: yourname_api1.example.com.', + 'API password' => 'Пароль API', + 'API signature' => 'Подпись API', + 'shop_paypal_signature_description' => 'Длинная строка из букв и цифр.', + 'Shopping cart image' => 'Изображение корзины', + 'shop_cart_image_description' => 'Изображение, которое показывается вместо текста ссылки на корзину. Рекомендуемый размер: до 50 × 50 пикселей.', + 'shop_price_item_font_color_description' => 'Цвет текста цены в каждой записи товара.', + 'shop_price_item_font_family_description' => 'Шрифт цены товара. Если ниже указан шрифт Google, используется он.', + 'Google web fonts' => 'Веб-шрифты Google', + 'shop_price_item_font_size_description' => 'Размер цены товара, например 12px или 1.2em.', + 'shop_price_item_font_weight_description' => 'Показывает цену товара обычным или жирным шрифтом.', + 'shop_price_item_font_style_description' => 'Показывает цену товара обычным шрифтом или курсивом.', + 'shop_price_item_font_variant_description' => 'small-caps показывает буквы в цене, например валюту, капителью.', + 'shop_price_item_line_height_description' => 'Высота строки текста цены товара, например 14px, 1.2em или normal.', + 'Shop entry width' => 'Ширина записи магазина', + 'shop_entry_width_description' => 'Ширина записей товаров в пикселях. Используется только шаблоном Messy, когда его макет не адаптивный. Собственная ширина записи имеет приоритет. Оставьте пустым для ширины по умолчанию.', + + // Settings and templates + 'Additional text' => 'Дополнительный текст', + 'Background video (embed)' => 'Фоновое видео (встроенное)', + 'Background video ratio' => 'Пропорции фонового видео', + 'Button color' => 'Цвет кнопок', + 'Centered content width' => 'Ширина центрированного содержимого', + 'Centered contents' => 'Центрированное содержимое', + 'Centered layout' => 'Центрированный макет', + 'Centering guides color tone' => 'Тон направляющих центрирования', + 'Choose "no" to hide page heading in first section.' => 'Выберите «No», чтобы скрыть заголовок страницы в первом разделе.', + 'Choose "yes" to show gridlines in background. Available only in Messy template.' => 'Выберите «Yes», чтобы показать сетку на фоне. Доступно только в шаблоне Messy.', + 'Choose theme' => 'Выберите тему', + 'Color of the fullscreen gallery background.' => 'Цвет фона полноэкранной галереи.', + 'Color tone for centering guides (dark for bright background colors, bright for dark background colors).' => 'Тон направляющих центрирования (тёмный для светлого фона, светлый для тёмного фона).', + 'Column count for Mash-up section.' => 'Количество колонок в разделе Mash-up.', + 'Columns' => 'Колонки', + 'Content width if layout is centered.' => 'Ширина содержимого при центрированном макете.', + 'Custom CSS' => 'Собственный CSS', + 'Custom social media buttons' => 'Собственные кнопки социальных сетей', + 'Displayed image will be half of the original size, full size will be used for hi-res displays.' => 'Изображение показывается в половину исходного размера, на экранах высокого разрешения используется полный размер.', + 'Enable navigation to page top' => 'Включить переход к началу страницы', + 'Enabled' => 'Включено', + 'Enables fullscreen gallery slide numbers.' => 'Включает нумерацию слайдов в полноэкранной галерее.', + 'Enables gallery fullscreen mode for new entries.' => 'Включает полноэкранный режим галереи для новых записей.', + 'Enables responsive layout for mobile devices.' => 'Включает адаптивный макет для мобильных устройств.', + 'Entries are organised in columns. Column count can be changed under section settings.' => 'Записи расположены в колонках. Количество колонок меняется в настройках раздела.', + 'Entry max width' => 'Максимальная ширина записи', + 'Entry padding' => 'Внутренний отступ записи', + 'Fill window' => 'Заполнить окно', + 'Gallery fullscreen settings:' => 'Настройки полноэкранной галереи:', + 'Google Analytics measurement ID' => 'Идентификатор потока данных Google Analytics', + 'Google Tag Manager container ID' => 'Идентификатор контейнера Google Tag Manager', + 'Google ownership verification tag. More info.' => 'Тег подтверждения прав на сайт в Google. Подробнее.', + 'Google site verification tag' => 'Тег подтверждения сайта Google', + 'Grid layout' => 'Сетка', + 'Gridlines color' => 'Цвет линий сетки', + 'HTML code' => 'HTML-код', + 'Heading margin' => 'Поля заголовка', + 'Hide copyrights' => 'Скрыть копирайт', + 'Hides Berta\'s copyrights' => 'Скрывает упоминание авторских прав Berta', + 'How far the sidebar is from the left side of the screen. This gets ignored, if centered layout is enabled.' => 'Расстояние боковой панели от левого края экрана. Не учитывается, если включён центрированный макет.', + 'How to get the measurement ID. Leave blank if Google Tag Manager container ID is filled.' => 'Как получить идентификатор потока данных. Оставьте пустым, если указан идентификатор контейнера Google Tag Manager.', + 'How to get the container ID. Leave blank if Google Analytics measurement ID is filled.' => 'Как получить идентификатор контейнера. Оставьте пустым, если указан идентификатор потока данных Google Analytics.', + 'Icon' => 'Значок', + 'Javascript code' => 'Код Javascript', + 'Javascript include' => 'Подключение Javascript', + 'Keep ratio' => 'Сохранять пропорции', + 'Link' => 'Ссылка', + 'Link address' => 'Адрес ссылки', + 'Location' => 'Расположение', + 'Location of social media buttons. Content of additional text or additional footer will be replaced.' => 'Расположение кнопок социальных сетей. Они заменят содержимое дополнительного текста или дополнительного нижнего колонтитула.', + 'Margin around menu.' => 'Отступ вокруг меню.', + 'Margin around page heading or logo.' => 'Отступ вокруг заголовка страницы или логотипа.', + 'Mash-up columns' => 'Колонки Mash-up', + 'Maximum size of a medium image (visible if \'Medium images\' are switched on in the gallery editor). These settings don\'t affect original image.' => 'Максимальный размер среднего изображения (видно, если в редакторе галереи включено «Medium images»). Эти настройки не меняют исходное изображение.', + 'Medium image height' => 'Высота среднего изображения', + 'Medium image width' => 'Ширина среднего изображения', + 'Menu margin' => 'Поля меню', + 'Name of your site. Visible in all sections.' => 'Название вашего сайта. Видно во всех разделах.', + 'Opens in' => 'Открывается в', + 'Page heading, menu items and section entries is horizontally centered to window.' => 'Заголовок страницы, пункты меню и записи раздела центрированы в окне по горизонтали.', + 'Paste or write your HTML code here.' => 'Вставьте или напишите здесь свой HTML-код.', + 'Paste or write your Javascript code here.' => 'Вставьте или напишите здесь свой код Javascript.', + 'Reset design' => 'Сбросить дизайн', + 'Reset design to default values.' => 'Возвращает дизайну значения по умолчанию.', + 'Responsive design' => 'Адаптивный дизайн', + 'Responsive layout' => 'Адаптивный макет', + 'Sets whether layout should be centered or not.' => 'Определяет, центрирован ли макет.', + 'Sets whether layout should be responsive or not.' => 'Определяет, адаптивный ли макет.', + 'Show gridlines' => 'Показывать сетку', + 'Show page heading in first section?' => 'Показывать заголовок страницы в первом разделе?', + 'Shows navigation button at the bottom when page is scrolled.' => 'Показывает кнопку навигации внизу, когда страница прокручена.', + 'Slideshow loop mode' => 'Зацикливание слайд-шоу', + 'Social media buttons' => 'Кнопки социальных сетей', + 'Social media links' => 'Ссылки на социальные сети', + 'Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.' => 'Шаблоны – это «скины» или темы вашего сайта. Вы можете выбрать один из шаблонов, установленных в папке шаблонов. Чтобы добавить в этот список новый шаблон, загрузите его в папку шаблонов по FTP.', + 'Texts' => 'Тексты', + 'Theme' => 'Тема', + 'Themes are ready made sites with predefined design settings.' => 'Темы – это готовые сайты с заранее заданными настройками дизайна.', ];