diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48b8bf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1efd347 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "websmid/trustpilotafsbcc", + "description": "BCCs the order confirmation e-mail to Trustpilot's AFS invitation address", + "type": "prestashop-module", + "authors": [ + { + "name": "Isabelle Oving-Anno - De Websmid BV", + "email": "isabelle@dewebsmid.nl" + } + ], + "require": { + "php": ">=8.1.0" + }, + "autoload": { + "psr-4": { + "Websmid\\TrustpilotAfsBcc\\": "src/" + }, + "classmap": [ + "ws_trustpilotafsbcc.php" + ], + "exclude-from-classmap": [] + }, + "config": { + "preferred-install": "dist", + "prepend-autoloader": false + }, + "license": "MIT" +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..54ccbd6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,20 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e29c932400094498561eeb7d4c1bf468", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1.0" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..022f87b Binary files /dev/null and b/logo.png differ diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..93d40a5 --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,93 @@ + 'dark', + 'Light' => 'light', + ]; + + const TRUSTBOX_SCRIPT_URL = 'https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js'; + + const TRUSTBOX_TEMPLATE_DIR = 'module:ws_trustpilotafsbcc/views/templates/hook/'; + + // Display hook => template rendered in it + const TRUSTBOX_HOOK_TEMPLATES = [ + 'displayTopUsp' => 'trustbox_desktop.tpl', + 'displayBanner' => 'trustbox_mobile.tpl', + ]; + + // Front controller (php_self) the TrustBox is shown on + const TRUSTBOX_PAGE = 'index'; + + // Language defaults, keyed by PrestaShop iso_code + + const DEFAULT_LOCALES = [ + 'nl' => 'nl-BE', + 'fr' => 'fr-BE', + 'en' => 'en-GB', + 'de' => 'de-DE', + ]; + + const PROFILE_HOSTS = [ + 'nl' => 'nl-be.trustpilot.com', + 'fr' => 'fr-be.trustpilot.com', + 'en' => 'www.trustpilot.com', + 'de' => 'de.trustpilot.com', + ]; + + const DEFAULT_PROFILE_HOST = 'www.trustpilot.com'; + + // Validation patterns + + const PATTERN_TRUSTPILOT_ID = '/^[a-f0-9]{24}$/i'; + + const PATTERN_LOCALE = '/^[a-z]{2}-[A-Z]{2}$/'; +} diff --git a/src/Module/AdminHooks.php b/src/Module/AdminHooks.php new file mode 100644 index 0000000..c49769e --- /dev/null +++ b/src/Module/AdminHooks.php @@ -0,0 +1,292 @@ +saveConfiguration(); + } + + $this->context->smarty->assign(array_merge( + $this->loadConfigurationValues(), + [ + 'languages' => Language::getLanguages(false), + 'trustbox_themes' => Config::TRUSTBOX_THEMES, + 'form_action' => $this->context->link->getAdminLink('AdminModules') . '&configure=' . $this->name, + 'confirmation' => $confirmation, + ] + )); + + return $this->fetch('module:ws_trustpilotafsbcc/views/templates/admin/configuration.tpl'); + } + + private function loadConfigurationValues(): array + { + return [ + 'bcc_email' => Configuration::get(Config::CONFIG_BCC_EMAIL), + 'trigger_templates' => Configuration::get(Config::CONFIG_TRIGGER_TEMPLATES), + 'afs_locales' => Configuration::getConfigInMultipleLangs(Config::CONFIG_AFS_LOCALE), + 'afs_template_ids' => Configuration::getConfigInMultipleLangs(Config::CONFIG_AFS_TEMPLATE_ID), + 'trustbox_enabled' => (bool) Configuration::get(Config::CONFIG_TRUSTBOX_ENABLED), + 'trustbox_business_unit_id' => Configuration::get(Config::CONFIG_TRUSTBOX_BUSINESS_UNIT_ID), + 'trustbox_template_id' => Configuration::get(Config::CONFIG_TRUSTBOX_TEMPLATE_ID), + 'trustbox_theme' => Configuration::get(Config::CONFIG_TRUSTBOX_THEME), + 'trustbox_locales' => Configuration::getConfigInMultipleLangs(Config::CONFIG_TRUSTBOX_LOCALE), + 'trustbox_profile_urls' => Configuration::getConfigInMultipleLangs(Config::CONFIG_TRUSTBOX_PROFILE_URL), + ]; + } + + private function saveConfiguration(): string + { + $values = $this->buildSubmittedValues(); + $errors = $this->validateSubmittedValues($values); + + if (!empty($errors)) { + return $this->displayError($errors); + } + + foreach ($values as $key => $value) { + Configuration::updateValue($key, $value); + } + + $this->clearTrustboxCache(); + + return $this->displayConfirmation($this->trans('Settings saved.', [], 'Admin.Notifications.Success')); + } + + private function buildSubmittedValues(): array + { + $theme = (string) Tools::getValue('trustbox_theme'); + + return [ + Config::CONFIG_BCC_EMAIL => trim((string) Tools::getValue('bcc_email')), + Config::CONFIG_TRIGGER_TEMPLATES => trim((string) Tools::getValue('trigger_templates')), + Config::CONFIG_AFS_LOCALE => $this->buildSubmittedLangValues('afs_locale'), + Config::CONFIG_AFS_TEMPLATE_ID => $this->buildSubmittedLangValues('afs_template_id'), + Config::CONFIG_TRUSTBOX_ENABLED => (int) (bool) Tools::getValue('trustbox_enabled'), + Config::CONFIG_TRUSTBOX_BUSINESS_UNIT_ID => trim((string) Tools::getValue('trustbox_business_unit_id')), + Config::CONFIG_TRUSTBOX_TEMPLATE_ID => trim((string) Tools::getValue('trustbox_template_id')), + Config::CONFIG_TRUSTBOX_THEME => in_array($theme, Config::TRUSTBOX_THEMES, true) ? $theme : Config::DEFAULT_TRUSTBOX_THEME, + Config::CONFIG_TRUSTBOX_LOCALE => $this->buildSubmittedLangValues('trustbox_locale'), + Config::CONFIG_TRUSTBOX_PROFILE_URL => $this->buildSubmittedLangValues('trustbox_profile_url'), + ]; + } + + private function buildSubmittedLangValues(string $field): array + { + $submitted = Tools::getValue($field); + $values = []; + + foreach (Language::getLanguages(false) as $language) { + $idLang = (int) $language['id_lang']; + $values[$idLang] = is_array($submitted) ? trim((string) ($submitted[$idLang] ?? '')) : ''; + } + + return $values; + } + + private function validateSubmittedValues(array $values): array + { + $errors = []; + $bccEmail = $values[Config::CONFIG_BCC_EMAIL]; + + if ($bccEmail !== '' && !Validate::isEmail($bccEmail)) { + $errors[] = $this->trans('Please enter a valid e-mail address.', [], 'Modules.Wstrustpilotafsbcc.Admin'); + } + + if (!$this->isValidTrustpilotId($values[Config::CONFIG_TRUSTBOX_BUSINESS_UNIT_ID]) + || !$this->isValidTrustpilotId($values[Config::CONFIG_TRUSTBOX_TEMPLATE_ID]) + || !$this->areValidTrustpilotIds($values[Config::CONFIG_AFS_TEMPLATE_ID]) + ) { + $errors[] = $this->trans('Trustpilot IDs must be 24 characters (0-9, a-f).', [], 'Modules.Wstrustpilotafsbcc.Admin'); + } + + if (!$this->areValidLocales($values[Config::CONFIG_AFS_LOCALE]) + || !$this->areValidLocales($values[Config::CONFIG_TRUSTBOX_LOCALE]) + ) { + $errors[] = $this->trans('Locales must look like nl-BE.', [], 'Modules.Wstrustpilotafsbcc.Admin'); + } + + foreach ($values[Config::CONFIG_TRUSTBOX_PROFILE_URL] as $profileUrl) { + if ($profileUrl !== '' && !Validate::isAbsoluteUrl($profileUrl)) { + $errors[] = $this->trans('Profile URLs must be full URLs starting with https://.', [], 'Modules.Wstrustpilotafsbcc.Admin'); + break; + } + } + + if ($values[Config::CONFIG_TRUSTBOX_ENABLED] && $values[Config::CONFIG_TRUSTBOX_BUSINESS_UNIT_ID] === '') { + $errors[] = $this->trans('The TrustBox needs a business unit ID before it can be enabled.', [], 'Modules.Wstrustpilotafsbcc.Admin'); + } + + return $errors; + } + + private function isValidTrustpilotId(string $value): bool + { + return $value === '' || preg_match(Config::PATTERN_TRUSTPILOT_ID, $value) === 1; + } + + private function areValidTrustpilotIds(array $values): bool + { + foreach ($values as $value) { + if (!$this->isValidTrustpilotId($value)) { + return false; + } + } + + return true; + } + + private function areValidLocales(array $values): bool + { + foreach ($values as $value) { + if ($value !== '' && preg_match(Config::PATTERN_LOCALE, $value) !== 1) { + return false; + } + } + + return true; + } + + private function clearTrustboxCache(): void + { + foreach (Config::TRUSTBOX_HOOK_TEMPLATES as $template) { + $this->_clearCache(Config::TRUSTBOX_TEMPLATE_DIR . $template); + } + } + + // In the PS core Mail::send() dispatches actionEmailSendBefore before it validates $bcc, passing + // bcc' => &$bcc by reference. Writing $params['bcc'] here rebinds that reference and + // propagates back to Mail::send()'s own $bcc variable + + public function hookActionEmailSendBefore(array $params) + { + if (!$this->isAfsActiveFor((string) ($params['template'] ?? ''))) { + return; + } + + $params['bcc'] = $this->buildBccValue($params['bcc'] ?? null, (string) Configuration::get(Config::CONFIG_BCC_EMAIL)); + } + + // Mail::send() passes 'extra_template_vars' => &$extraTemplateVars and merges it into the + // template vars right before strtr() fills in the HTML, so the JSON lands in the placeholder + // injected by hookActionEmailAddAfterContent below + + public function hookActionGetExtraMailTemplateVars(array $params) + { + if (!$this->isAfsActiveFor((string) ($params['template'] ?? ''))) { + return; + } + + $params['extra_template_vars'][Config::AFS_SNIPPET_VAR] = $this->buildAfsSnippetJson( + (array) ($params['template_vars'] ?? []), + (int) ($params['id_lang'] ?? 0) + ); + } + + // Mail::send() passes 'template_html' => &$templateHtml after loading the template file and + // before the template vars are applied. Only the HTML part gets the snippet + + public function hookActionEmailAddAfterContent(array $params) + { + if (!$this->isAfsActiveFor((string) ($params['template'] ?? ''))) { + return; + } + + $params['template_html'] = $this->buildHtmlWithSnippet( + (string) ($params['template_html'] ?? ''), + $this->renderAfsSnippet() + ); + } + + private function isAfsActiveFor(string $template): bool + { + $bccEmail = (string) Configuration::get(Config::CONFIG_BCC_EMAIL); + + if ($bccEmail === '' || !Validate::isEmail($bccEmail)) { + return false; + } + + return in_array($template, $this->loadTriggerTemplates(), true); + } + + private function loadTriggerTemplates(): array + { + $raw = (string) Configuration::get(Config::CONFIG_TRIGGER_TEMPLATES); + + return array_filter(array_map('trim', explode(',', $raw))); + } + + private function buildBccValue($existingBcc, string $bccEmail) + { + if (is_array($existingBcc)) { + $existingBcc[] = $bccEmail; + + return $existingBcc; + } + + if (!empty($existingBcc)) { + return [$existingBcc, $bccEmail]; + } + + return $bccEmail; + } + + private function buildAfsSnippetJson(array $templateVars, int $idLang): string + { + $firstname = (string) ($templateVars['{firstname}'] ?? ''); + $lastname = (string) ($templateVars['{lastname}'] ?? ''); + + $data = array_filter([ + 'recipientEmail' => (string) ($templateVars['{email}'] ?? ''), + 'recipientName' => trim($firstname . ' ' . $lastname), + 'referenceId' => (string) ($templateVars['{order_name}'] ?? ''), + 'locale' => (string) Configuration::get(Config::CONFIG_AFS_LOCALE, $idLang), + 'templateId' => (string) Configuration::get(Config::CONFIG_AFS_TEMPLATE_ID, $idLang), + ], static function (string $value) { + return $value !== ''; + }); + + // HEX flags keep <, >, &, ' and " out of the output, so it is safe inside a diff --git a/views/templates/hook/trustbox_desktop.tpl b/views/templates/hook/trustbox_desktop.tpl new file mode 100644 index 0000000..78d0048 --- /dev/null +++ b/views/templates/hook/trustbox_desktop.tpl @@ -0,0 +1,3 @@ +