web-to-lead forms
This commit is contained in:
@@ -60,6 +60,7 @@ class ExampleLoader implements Loader
|
||||
|
||||
$this->processRequestUrl($entity);
|
||||
$this->processRequestPayload($entity);
|
||||
$this->processFormUrl($entity);
|
||||
}
|
||||
|
||||
private function processRequestUrl(LeadCapture $entity): void
|
||||
@@ -121,4 +122,21 @@ class ExampleLoader implements Loader
|
||||
|
||||
$entity->set('exampleRequestPayload', $requestPayload);
|
||||
}
|
||||
|
||||
private function processFormUrl(LeadCapture $entity): void
|
||||
{
|
||||
$formId = $entity->getFormId();
|
||||
$siteUrl = $this->config->getSiteUrl();
|
||||
|
||||
if (!$entity->hasFormEnabled() || !$formId) {
|
||||
/** @noinspection PhpRedundantOptionalArgumentInspection */
|
||||
$entity->set('formUrl', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$formUrl = "$siteUrl?entryPoint=leadCaptureForm&id=$formId";
|
||||
|
||||
$entity->set('formUrl', $formUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,7 @@ class BeforeCreate implements SaveHook
|
||||
|
||||
public function process(Entity $entity): void
|
||||
{
|
||||
$apiKey = $this->service->generateApiKey();
|
||||
|
||||
$entity->setApiKey($apiKey);
|
||||
$entity->setApiKey($this->service->generateApiKey());
|
||||
$entity->setFormId($this->service->generateFormId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,24 @@ class LeadCapture extends Record
|
||||
->getValueMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function postActionGenerateNewFormId(Request $request): stdClass
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
if (empty($data->id)) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
return $this->getLeadCaptureService()
|
||||
->generateNewFormIdForEntity($data->id)
|
||||
->getValueMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass[]
|
||||
* @throws Forbidden
|
||||
|
||||
@@ -166,9 +166,21 @@ class FieldValidationManager
|
||||
|
||||
/**
|
||||
* Check a specific field for a specific validation type.
|
||||
*
|
||||
* @param Entity $entity An entity to check.
|
||||
* @param string $field A field to check.
|
||||
* @param string $type A validation type.
|
||||
* @param ?stdClass $data A payload.
|
||||
* @param mixed $value To override a validation value.
|
||||
*/
|
||||
public function check(Entity $entity, string $field, string $type, ?stdClass $data = null): bool
|
||||
{
|
||||
public function check(
|
||||
Entity $entity,
|
||||
string $field,
|
||||
string $type,
|
||||
?stdClass $data = null,
|
||||
mixed $value = null
|
||||
): bool {
|
||||
|
||||
$data ??= (object) [];
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
@@ -178,7 +190,7 @@ class FieldValidationManager
|
||||
return false;
|
||||
}
|
||||
|
||||
$validationValue = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type);
|
||||
$validationValue = $value ?? $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type);
|
||||
$isMandatory = in_array($type, $this->getMandatoryValidationList($entityType, $field));
|
||||
|
||||
$skip = !$isMandatory && (is_null($validationValue) || $validationValue === false);
|
||||
|
||||
@@ -30,21 +30,19 @@
|
||||
namespace Espo\Core\Loaders;
|
||||
|
||||
use Espo\Core\Container\Loader;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Language as LanguageService;
|
||||
use Espo\Core\Utils\Language;
|
||||
|
||||
class BaseLanguage implements Loader
|
||||
{
|
||||
public function __construct(private InjectableFactory $injectableFactory, protected Config $config)
|
||||
{}
|
||||
public function __construct(
|
||||
private Language\LanguageFactory $languageFactory,
|
||||
protected Config $config
|
||||
) {}
|
||||
|
||||
public function load(): LanguageService
|
||||
public function load(): Language
|
||||
{
|
||||
return $this->injectableFactory->createWith(LanguageService::class, [
|
||||
'language' => $this->getLanguage(),
|
||||
'useCache' => $this->config->get('useCache') ?? false,
|
||||
]);
|
||||
return $this->languageFactory->create($this->getLanguage());
|
||||
}
|
||||
|
||||
protected function getLanguage(): string
|
||||
|
||||
@@ -52,19 +52,30 @@ class ActionRenderer
|
||||
$params->getController(),
|
||||
$params->getAction(),
|
||||
$params->getData(),
|
||||
$params->initAuth()
|
||||
$params->initAuth(),
|
||||
$params->getScripts(),
|
||||
);
|
||||
|
||||
$this->clientManager->writeHeaders($response);
|
||||
$securityParams = new SecurityParams(
|
||||
frameAncestors: $params->getFrameAncestors(),
|
||||
);
|
||||
|
||||
$this->clientManager->writeHeaders($response, $securityParams);
|
||||
$response->writeBody($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use`write`.
|
||||
* @param ?array<string, mixed> $data
|
||||
* @param Script[] $scripts
|
||||
*/
|
||||
public function render(string $controller, string $action, ?array $data = null, bool $initAuth = false): string
|
||||
{
|
||||
private function render(
|
||||
string $controller,
|
||||
string $action,
|
||||
?array $data,
|
||||
bool $initAuth,
|
||||
array $scripts,
|
||||
): string {
|
||||
|
||||
$encodedData = Json::encode($data);
|
||||
|
||||
$initAuthPart = $initAuth ? "app.initAuth();" : '';
|
||||
@@ -73,12 +84,17 @@ class ActionRenderer
|
||||
"
|
||||
{$initAuthPart}
|
||||
app.doAction({
|
||||
controllerClassName: '{$controller}',
|
||||
action: '{$action}',
|
||||
options: {$encodedData},
|
||||
controllerClassName: '$controller',
|
||||
action: '$action',
|
||||
options: $encodedData,
|
||||
});
|
||||
";
|
||||
|
||||
return $this->clientManager->render($script);
|
||||
$params = new RenderParams(
|
||||
runScript: $script,
|
||||
scripts: $scripts,
|
||||
);
|
||||
|
||||
return $this->clientManager->render($params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
namespace Espo\Core\Utils\Client\ActionRenderer;
|
||||
|
||||
use Espo\Core\Utils\Client\Script;
|
||||
|
||||
/**
|
||||
* @immutable
|
||||
*/
|
||||
@@ -37,6 +39,10 @@ class Params
|
||||
/** @var ?array<string, mixed> */
|
||||
private ?array $data;
|
||||
private bool $initAuth = false;
|
||||
/** @var string[] */
|
||||
private array $frameAncestors = [];
|
||||
/** @var Script[] */
|
||||
private array $scripts = [];
|
||||
|
||||
/**
|
||||
* @param ?array<string, mixed> $data
|
||||
@@ -76,6 +82,30 @@ class Params
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $frameAncestors
|
||||
* @since 9.0.0
|
||||
*/
|
||||
public function withFrameAncestors(array $frameAncestors): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->frameAncestors = $frameAncestors;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Script[] $scripts
|
||||
* @since 9.0.0
|
||||
*/
|
||||
public function withScripts(array $scripts): self
|
||||
{
|
||||
$obj = clone $this;
|
||||
$obj->scripts = $scripts;
|
||||
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function getController(): string
|
||||
{
|
||||
return $this->controller;
|
||||
@@ -98,4 +128,22 @@ class Params
|
||||
{
|
||||
return $this->initAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
* @since 9.0.0
|
||||
*/
|
||||
public function getFrameAncestors(): array
|
||||
{
|
||||
return $this->frameAncestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Script[]
|
||||
* @since 9.0.0
|
||||
*/
|
||||
public function getScripts(): array
|
||||
{
|
||||
return $this->scripts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Utils\Client;
|
||||
|
||||
class RenderParams
|
||||
{
|
||||
/**
|
||||
* @param Script[] $scripts
|
||||
*/
|
||||
public function __construct(
|
||||
readonly public ?string $runScript = null,
|
||||
readonly public array $scripts = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Utils\Client;
|
||||
|
||||
class Script
|
||||
{
|
||||
public function __construct(
|
||||
readonly public string $source,
|
||||
readonly public bool $cacheBusting = false,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Utils\Client;
|
||||
|
||||
class SecurityParams
|
||||
{
|
||||
/**
|
||||
* @param string[] $frameAncestors
|
||||
*/
|
||||
public function __construct(
|
||||
readonly public array $frameAncestors = [],
|
||||
) {}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseWrapper;
|
||||
use Espo\Core\Utils\Client\DevModeJsFileListProvider;
|
||||
use Espo\Core\Utils\Client\LoaderParamsProvider;
|
||||
use Espo\Core\Utils\Client\RenderParams;
|
||||
use Espo\Core\Utils\Client\Script;
|
||||
use Espo\Core\Utils\Client\SecurityParams;
|
||||
use Espo\Core\Utils\File\Manager as FileManager;
|
||||
|
||||
use Slim\Psr7\Response as Psr7Response;
|
||||
@@ -62,7 +65,7 @@ class ClientManager
|
||||
private FileManager $fileManager,
|
||||
private DevModeJsFileListProvider $devModeJsFileListProvider,
|
||||
private Module $module,
|
||||
private LoaderParamsProvider $loaderParamsProvider
|
||||
private LoaderParamsProvider $loaderParamsProvider,
|
||||
) {
|
||||
$this->nonce = Util::generateKey();
|
||||
}
|
||||
@@ -80,43 +83,45 @@ class ClientManager
|
||||
/**
|
||||
* @todo Move to a separate class.
|
||||
*/
|
||||
public function writeHeaders(Response $response): void
|
||||
public function writeHeaders(Response $response, ?SecurityParams $params = null): void
|
||||
{
|
||||
if ($this->config->get('clientSecurityHeadersDisabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params ??= new SecurityParams();
|
||||
|
||||
$response->setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
$this->writeXFrameOptionsHeader($response);
|
||||
$this->writeContentSecurityPolicyHeader($response);
|
||||
$this->writeContentSecurityPolicyHeader($response, $params);
|
||||
$this->writeStrictTransportSecurityHeader($response);
|
||||
}
|
||||
|
||||
private function writeXFrameOptionsHeader(Response $response): void
|
||||
{
|
||||
if ($this->config->get('clientXFrameOptionsHeaderDisabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
}
|
||||
|
||||
private function writeContentSecurityPolicyHeader(Response $response): void
|
||||
private function writeContentSecurityPolicyHeader(Response $response, SecurityParams $params): void
|
||||
{
|
||||
if ($this->config->get('clientCspDisabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scriptSrc = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'";
|
||||
$string = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'";
|
||||
|
||||
/** @var string[] $scriptSourceList */
|
||||
$scriptSourceList = $this->config->get('clientCspScriptSourceList') ?? [];
|
||||
|
||||
foreach ($scriptSourceList as $src) {
|
||||
$scriptSrc .= ' ' . $src;
|
||||
$string .= ' ' . $src;
|
||||
}
|
||||
|
||||
$response->setHeader('Content-Security-Policy', $scriptSrc);
|
||||
// Checking the parameter for bc.
|
||||
if (!$this->config->get('clientXFrameOptionsHeaderDisabled')) {
|
||||
$string .= '; frame-ancestors';
|
||||
|
||||
foreach (["'self'", ...$params->frameAncestors] as $item) {
|
||||
$string .= ' ' . $item;
|
||||
}
|
||||
}
|
||||
|
||||
$response->setHeader('Content-Security-Policy', $string);
|
||||
}
|
||||
|
||||
private function writeStrictTransportSecurityHeader(Response $response): void
|
||||
@@ -137,7 +142,7 @@ class ClientManager
|
||||
*/
|
||||
public function display(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): void
|
||||
{
|
||||
$body = $this->render($runScript, $htmlFilePath, $vars);
|
||||
$body = $this->renderInternal($runScript, $htmlFilePath, $vars);
|
||||
|
||||
$response = new ResponseWrapper(new Psr7Response());
|
||||
|
||||
@@ -148,10 +153,32 @@ class ClientManager
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $vars
|
||||
* Render.
|
||||
*
|
||||
* @param RenderParams $params Parameters.
|
||||
* @return string A result HTML.
|
||||
*/
|
||||
public function render(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): string
|
||||
public function render(RenderParams $params): string
|
||||
{
|
||||
return $this->renderInternal(
|
||||
$params->runScript,
|
||||
null,
|
||||
[],
|
||||
$params->scripts
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $vars
|
||||
* @param Script[] $additionalScripts
|
||||
*/
|
||||
private function renderInternal(
|
||||
?string $runScript = null,
|
||||
?string $htmlFilePath = null,
|
||||
array $vars = [],
|
||||
array $additionalScripts = []
|
||||
): string {
|
||||
|
||||
$runScript ??= $this->runScript;
|
||||
$htmlFilePath ??= $this->mainHtmlFilePath;
|
||||
|
||||
@@ -176,6 +203,10 @@ class ClientManager
|
||||
array_map(fn ($file) => $this->getScriptItemHtml($file, $appTimestamp), $jsFileList)
|
||||
);
|
||||
|
||||
foreach ($additionalScripts as $it) {
|
||||
$scriptsHtml .= $this->getScriptItemHtml(null, $appTimestamp, true, $it->cacheBusting, $it->source);
|
||||
}
|
||||
|
||||
$additionalStyleSheetsHtml = implode('',
|
||||
array_map(fn ($file) => $this->getCssItemHtml($file, $appTimestamp), $cssFileList)
|
||||
);
|
||||
@@ -295,12 +326,28 @@ class ClientManager
|
||||
return $this->config->get('appTimestamp', 0);
|
||||
}
|
||||
|
||||
private function getScriptItemHtml(string $file, int $appTimestamp): string
|
||||
{
|
||||
$src = $this->basePath . $file . '?r=' . $appTimestamp;
|
||||
private function getScriptItemHtml(
|
||||
?string $file,
|
||||
int $appTimestamp,
|
||||
bool $withNonce = false,
|
||||
bool $cache = true,
|
||||
?string $source = null,
|
||||
): string {
|
||||
|
||||
$src = $source ?? $this->basePath . $file;
|
||||
|
||||
if ($cache) {
|
||||
$src .= '?r=' . $appTimestamp;
|
||||
}
|
||||
|
||||
$noncePart = '';
|
||||
|
||||
if ($withNonce) {
|
||||
$noncePart = " nonce=\"$this->nonce\"";
|
||||
}
|
||||
|
||||
return $this->getTabHtml() .
|
||||
"<script type=\"text/javascript\" src=\"$src\" data-base-path=\"$this->basePath\"></script>";
|
||||
"<script type=\"text/javascript\" src=\"$src\" data-base-path=\"$this->basePath\"$noncePart></script>";
|
||||
}
|
||||
|
||||
private function getCssItemHtml(string $file, int $appTimestamp): string
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Utils\Language;
|
||||
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Language;
|
||||
|
||||
class LanguageFactory
|
||||
{
|
||||
public function __construct(
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
public function create(string $language): Language
|
||||
{
|
||||
return $this->injectableFactory->createWith(Language::class, [
|
||||
'language' => $language,
|
||||
'useCache' => $this->config->get('useCache') ?? false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,9 @@ class ThemeManager
|
||||
{
|
||||
return $this->metadata->get(['themes', $this->getName(), 'logo']) ?? $this->defaultLogoSrc;
|
||||
}
|
||||
|
||||
public function isDark(): bool
|
||||
{
|
||||
return (bool) $this->metadata->get("themes.{$this->getName()}.isDark");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Entity;
|
||||
use stdClass;
|
||||
|
||||
class LeadCapture extends Entity
|
||||
{
|
||||
@@ -44,6 +45,49 @@ class LeadCapture extends Entity
|
||||
return $this->get('subscribeToTargetList') && $this->get('subscribeContactToTargetList');
|
||||
}
|
||||
|
||||
public function hasFormCaptcha(): bool
|
||||
{
|
||||
return (bool) $this->get('formCaptcha');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getFormFrameAncestors(): array
|
||||
{
|
||||
return $this->get('formFrameAncestors') ?? [];
|
||||
}
|
||||
|
||||
public function getFormText(): ?string
|
||||
{
|
||||
return $this->get('formText');
|
||||
}
|
||||
|
||||
public function getFormSuccessText(): ?string
|
||||
{
|
||||
return $this->get('formSuccessText');
|
||||
}
|
||||
|
||||
public function getFormLanguage(): ?string
|
||||
{
|
||||
return $this->get('formLanguage');
|
||||
}
|
||||
|
||||
public function getFormSuccessRedirectUrl(): ?string
|
||||
{
|
||||
$url = $this->get('formSuccessRedirectUrl');
|
||||
|
||||
if (!$url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!str_contains($url, '://')) {
|
||||
$url = 'https://' . $url;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
@@ -52,6 +96,16 @@ class LeadCapture extends Entity
|
||||
return $this->get('fieldList') ?? [];
|
||||
}
|
||||
|
||||
public function isFieldRequired(string $field): bool
|
||||
{
|
||||
/** @var stdClass $fieldParams */
|
||||
$fieldParams = $this->get('fieldParams') ?? (object) [];
|
||||
/** @var stdClass $itParams */
|
||||
$itParams = $fieldParams->$field ?? (object) [];
|
||||
|
||||
return (bool) ($itParams->required ?? false);
|
||||
}
|
||||
|
||||
public function getOptInConfirmationSuccessMessage(): ?string
|
||||
{
|
||||
return $this->get('optInConfirmationSuccessMessage');
|
||||
@@ -92,6 +146,16 @@ class LeadCapture extends Entity
|
||||
return (bool) $this->get('subscribeContactToTargetList');
|
||||
}
|
||||
|
||||
public function getFormId(): ?string
|
||||
{
|
||||
return $this->get('formId');
|
||||
}
|
||||
|
||||
public function setFormId(string $apiKey): self
|
||||
{
|
||||
return $this->set('formId', $apiKey);
|
||||
}
|
||||
|
||||
public function getApiKey(): ?string
|
||||
{
|
||||
return $this->get('apiKey');
|
||||
@@ -146,4 +210,9 @@ class LeadCapture extends Entity
|
||||
{
|
||||
return $this->get('phoneNumberCountry');
|
||||
}
|
||||
|
||||
public function hasFormEnabled(): bool
|
||||
{
|
||||
return (bool) $this->get('formEnabled');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\EntryPoints;
|
||||
|
||||
use Espo\Core\Utils\Client\Script;
|
||||
use Espo\Tools\LeadCapture\FormService;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\EntryPoint\EntryPoint;
|
||||
use Espo\Core\EntryPoint\Traits\NoAuth;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Utils\Client\ActionRenderer;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class LeadCaptureForm implements EntryPoint
|
||||
{
|
||||
use NoAuth;
|
||||
|
||||
public function __construct(
|
||||
private ActionRenderer $actionRenderer,
|
||||
private FormService $service,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function run(Request $request, Response $response): void
|
||||
{
|
||||
$id = $request->getQueryParam('id');
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest("No ID.");
|
||||
}
|
||||
|
||||
[$leadCapture, $data, $captchaScript] = $this->service->getData($id);
|
||||
|
||||
$params = new ActionRenderer\Params('controllers/lead-capture-form', 'show', $data);
|
||||
$params = $params->withFrameAncestors($leadCapture->getFormFrameAncestors());
|
||||
|
||||
if ($captchaScript) {
|
||||
$params = $params->withScripts([new Script(source: $captchaScript)]);
|
||||
}
|
||||
|
||||
$this->actionRenderer->write($response, $params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Hooks\LeadCapture;
|
||||
|
||||
use Espo\Core\Hook\Hook\AfterSave;
|
||||
use Espo\Core\Utils\DataCache;
|
||||
use Espo\Entities\LeadCapture;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
|
||||
/**
|
||||
* @implements AfterSave<LeadCapture>
|
||||
*/
|
||||
class ClearCache implements AfterSave
|
||||
{
|
||||
private const CACHE_KEY_PREFIX = 'leadCaptureForm';
|
||||
|
||||
public function __construct(
|
||||
private DataCache $dataCache,
|
||||
) {}
|
||||
|
||||
public function afterSave(Entity $entity, SaveOptions $options): void
|
||||
{
|
||||
if ($entity->isNew()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dataCache->clear($this->getCacheKey($entity));
|
||||
}
|
||||
|
||||
private function getCacheKey(LeadCapture $leadCapture): string
|
||||
{
|
||||
return self::CACHE_KEY_PREFIX . '/' . $leadCapture->getId();
|
||||
}
|
||||
}
|
||||
@@ -177,4 +177,14 @@ class Lead extends Person
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSource(?string $source): self
|
||||
{
|
||||
return $this->set('source', $source);
|
||||
}
|
||||
|
||||
public function setCampaign(Link|Campaign|null $campaign): self
|
||||
{
|
||||
return $this->setRelatedLinkOrEntity('campaign', $campaign);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
"authLog": "Login history.",
|
||||
"appLog": "Application log.",
|
||||
"appSecrets": "Store sensitive information like API keys, passwords, and other secrets.",
|
||||
"leadCapture": "API entry points for Web-to-Lead.",
|
||||
"leadCapture": "Lead capture endpoints and web forms.",
|
||||
"attachments": "All file attachments stored in the system.",
|
||||
"systemRequirements": "System Requirements for EspoCRM.",
|
||||
"apiUsers": "Separate users for integration purposes.",
|
||||
|
||||
@@ -4,16 +4,21 @@
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "Redirect URI",
|
||||
"apiKey": "API Key"
|
||||
"apiKey": "API Key",
|
||||
"siteKey": "Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"scoreThreshold": "Score Threshold"
|
||||
},
|
||||
"titles": {
|
||||
"GoogleMaps": "Google Maps"
|
||||
"GoogleMaps": "Google Maps",
|
||||
"GoogleReCaptcha": "Google reCAPTCHA"
|
||||
},
|
||||
"messages": {
|
||||
"selectIntegration": "Select an integration from menu.",
|
||||
"noIntegrations": "No Integrations is available."
|
||||
},
|
||||
"help": {
|
||||
"GoogleReCaptcha": "Obtain the Site Key and Secret Key from [Google](https://www.google.com/recaptcha/).",
|
||||
"Google": "**Obtain OAuth 2.0 credentials from the Google Developers Console.**\n\nVisit [Google Developers Console](https://console.developers.google.com/project) to obtain OAuth 2.0 credentials such as a Client ID and Client Secret that are known to both Google and EspoCRM application.",
|
||||
"GoogleMaps": "Obtain API key [here](https://developers.google.com/maps/documentation/javascript/get-api-key)."
|
||||
}
|
||||
|
||||
@@ -23,7 +23,17 @@
|
||||
"smtpAccount": "SMTP Account",
|
||||
"inboundEmail": "Group Email Account",
|
||||
"duplicateCheck": "Duplicate Check",
|
||||
"phoneNumberCountry": "Telephone country code"
|
||||
"phoneNumberCountry": "Telephone country code",
|
||||
"fieldParams": "Field Params",
|
||||
"formId": "Form ID",
|
||||
"formEnabled": "Web Form",
|
||||
"formUrl": "Form URL",
|
||||
"formSuccessText": "Text to display after form submission",
|
||||
"formText": "Text to display on form",
|
||||
"formSuccessRedirectUrl": "URL to redirect to after form submission",
|
||||
"formLanguage": "Language used on form",
|
||||
"formFrameAncestors": "Allowed hosts for form embedding",
|
||||
"formCaptcha": "Use Captcha"
|
||||
},
|
||||
"links": {
|
||||
"targetList": "Target List",
|
||||
@@ -37,7 +47,9 @@
|
||||
"Create LeadCapture": "Create Entry Point",
|
||||
"Generate New API Key": "Generate New API Key",
|
||||
"Request": "Request",
|
||||
"Confirm Opt-In": "Confirm Opt-In"
|
||||
"Confirm Opt-In": "Confirm Opt-In",
|
||||
"Generate New Form ID": "Generate New Form ID",
|
||||
"Web Form": "Web Form"
|
||||
},
|
||||
"messages": {
|
||||
"generateApiKey": "Create new API Key",
|
||||
@@ -45,6 +57,7 @@
|
||||
"optInIsConfirmed": "Opt-in is confirmed."
|
||||
},
|
||||
"tooltips": {
|
||||
"formCaptcha": "To be able to use Captcha, you need to configure it under **Administration** > **Integrations**.",
|
||||
"optInConfirmationSuccessMessage": "Markdown is supported."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,20 @@
|
||||
[{"name": "subscribeToTargetList", "inlineEditDisabled": true}, {"name": "campaign"}],
|
||||
[{"name": "subscribeContactToTargetList"}, {"name": "targetList"}],
|
||||
[{"name": "targetTeam"}, {"name": "leadSource"}],
|
||||
[{"name": "fieldList"}],
|
||||
[{"name": "duplicateCheck"}, {"name": "phoneNumberCountry"}],
|
||||
[{"name": "apiKey"}, false]
|
||||
[{"name": "duplicateCheck"}, {"name": "phoneNumberCountry"}]
|
||||
]
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
[{"name": "fieldList"}, {"name": "apiKey"}]
|
||||
]
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
[{"name": "formEnabled"}, false],
|
||||
[{"name": "formText"}, {"name": "formSuccessText"}],
|
||||
[{"name": "formSuccessRedirectUrl"}, {"name": "formLanguage"}],
|
||||
[{"name": "formFrameAncestors"}, {"name": "formCaptcha"}]
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,6 +143,82 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"formSuccessText": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"formText": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"formSuccessRedirectUrl": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"formLanguage": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"formFrameAncestors": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"formCaptcha": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"panels": {
|
||||
"form": {
|
||||
"visible": {
|
||||
"conditionGroup": [
|
||||
{
|
||||
"type": "isNotEmpty",
|
||||
"attribute": "id"
|
||||
},
|
||||
{
|
||||
"type": "isTrue",
|
||||
"attribute": "formEnabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -152,7 +228,15 @@
|
||||
"name": "request",
|
||||
"label": "Request",
|
||||
"isForm": true,
|
||||
"view": "views/lead-capture/record/panels/request"
|
||||
"view": "views/lead-capture/record/panels/request",
|
||||
"notRefreshable": true
|
||||
},
|
||||
{
|
||||
"name": "form",
|
||||
"label": "Web Form",
|
||||
"isForm": true,
|
||||
"view": "views/lead-capture/record/panels/form",
|
||||
"notRefreshable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,27 +5,33 @@
|
||||
"maxLength": 100
|
||||
},
|
||||
"campaign": {
|
||||
"type": "link"
|
||||
"type": "link",
|
||||
"audited": true
|
||||
},
|
||||
"isActive": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
"default": true,
|
||||
"audited": true
|
||||
},
|
||||
"subscribeToTargetList": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
"default": true,
|
||||
"audited": true
|
||||
},
|
||||
"subscribeContactToTargetList": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
"default": true,
|
||||
"audited": true
|
||||
},
|
||||
"targetList": {
|
||||
"type": "link"
|
||||
"type": "link",
|
||||
"audited": true
|
||||
},
|
||||
"fieldList": {
|
||||
"type": "multiEnum",
|
||||
"default": ["firstName", "lastName", "emailAddress"],
|
||||
"view": "views/lead-capture/fields/field-list",
|
||||
"displayAsList": true,
|
||||
"required": true,
|
||||
"ignoreFieldList": [
|
||||
"targetList",
|
||||
@@ -45,47 +51,117 @@
|
||||
"phoneNumberIsInvalid",
|
||||
"opportunityAmountCurrency",
|
||||
"originalEmail"
|
||||
]
|
||||
],
|
||||
"webFormFieldTypeList": [
|
||||
"varchar",
|
||||
"email",
|
||||
"phone",
|
||||
"text",
|
||||
"personName",
|
||||
"enum",
|
||||
"multiEnum",
|
||||
"array",
|
||||
"checklist",
|
||||
"int",
|
||||
"float",
|
||||
"currency",
|
||||
"date",
|
||||
"datetime",
|
||||
"boolean",
|
||||
"url",
|
||||
"urlMultiple",
|
||||
"address"
|
||||
],
|
||||
"audited": true
|
||||
},
|
||||
"fieldParams": {
|
||||
"type": "jsonObject",
|
||||
"audited": true
|
||||
},
|
||||
"duplicateCheck": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
"default": true,
|
||||
"audited": true
|
||||
},
|
||||
"optInConfirmation": {
|
||||
"type": "bool"
|
||||
"type": "bool",
|
||||
"audited": true
|
||||
},
|
||||
"optInConfirmationEmailTemplate": {
|
||||
"type": "link"
|
||||
"type": "link",
|
||||
"audited": true
|
||||
},
|
||||
"optInConfirmationLifetime": {
|
||||
"type": "int",
|
||||
"default": 48,
|
||||
"min": 1
|
||||
"min": 1,
|
||||
"audited": true
|
||||
},
|
||||
"optInConfirmationSuccessMessage": {
|
||||
"type": "text",
|
||||
"tooltip": true
|
||||
"tooltip": true,
|
||||
"audited": true
|
||||
},
|
||||
"createLeadBeforeOptInConfirmation": {
|
||||
"type": "bool"
|
||||
"type": "bool",
|
||||
"audited": true
|
||||
},
|
||||
"skipOptInConfirmationIfSubscribed": {
|
||||
"type": "bool"
|
||||
"type": "bool",
|
||||
"audited": true
|
||||
},
|
||||
"leadSource": {
|
||||
"type": "enum",
|
||||
"customizationOptionsDisabled": true,
|
||||
"optionsPath": "entityDefs.Lead.fields.source.options",
|
||||
"translation": "Lead.options.source",
|
||||
"default": "Web Site"
|
||||
"default": "Web Site",
|
||||
"audited": true
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "varchar",
|
||||
"maxLength": 36,
|
||||
"readOnly": true
|
||||
},
|
||||
"formId": {
|
||||
"type": "varchar",
|
||||
"maxLength": 17,
|
||||
"readOnly": true
|
||||
},
|
||||
"formEnabled": {
|
||||
"type": "bool",
|
||||
"audited": true
|
||||
},
|
||||
"formText": {
|
||||
"type": "text",
|
||||
"tooltip": "optInConfirmationSuccessMessage"
|
||||
},
|
||||
"formSuccessText": {
|
||||
"type": "text",
|
||||
"tooltip": "optInConfirmationSuccessMessage"
|
||||
},
|
||||
"formSuccessRedirectUrl": {
|
||||
"type": "url",
|
||||
"audited": true
|
||||
},
|
||||
"formLanguage": {
|
||||
"type": "enum",
|
||||
"maxLength": 5,
|
||||
"view": "views/preferences/fields/language",
|
||||
"audited": true
|
||||
},
|
||||
"formFrameAncestors": {
|
||||
"type": "urlMultiple",
|
||||
"audited": true
|
||||
},
|
||||
"formCaptcha": {
|
||||
"type": "bool",
|
||||
"audited": true,
|
||||
"tooltip": true
|
||||
},
|
||||
"targetTeam": {
|
||||
"type": "link"
|
||||
"type": "link",
|
||||
"audited": true
|
||||
},
|
||||
"exampleRequestUrl": {
|
||||
"type": "varchar",
|
||||
@@ -109,8 +185,16 @@
|
||||
"notStorable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"formUrl": {
|
||||
"type": "url",
|
||||
"notStorable": true,
|
||||
"readOnly": true,
|
||||
"copyToClipboard": true,
|
||||
"index": true
|
||||
},
|
||||
"inboundEmail": {
|
||||
"type": "link"
|
||||
"type": "link",
|
||||
"audited": true
|
||||
},
|
||||
"smtpAccount": {
|
||||
"type": "base",
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
{
|
||||
"name": "audited",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "onlyPrimary",
|
||||
"type": "bool",
|
||||
"hidden": true
|
||||
}
|
||||
],
|
||||
"actualFields": [
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
{
|
||||
"name": "audited",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "onlyPrimary",
|
||||
"type": "bool",
|
||||
"hidden": true
|
||||
}
|
||||
],
|
||||
"actualFields": [
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"fields": {
|
||||
"siteKey": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255,
|
||||
"required": true
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "password",
|
||||
"maxLength": 255,
|
||||
"required": true
|
||||
},
|
||||
"scoreThreshold": {
|
||||
"type": "float",
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"default": 0.3,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"allowUserAccounts": false,
|
||||
"view": "views/admin/integrations/edit"
|
||||
}
|
||||
@@ -78,6 +78,12 @@
|
||||
"method": "get",
|
||||
"actionClassName": "Espo\\Tools\\GlobalSearch\\Api\\Get"
|
||||
},
|
||||
{
|
||||
"route": "/LeadCapture/form/:id",
|
||||
"method": "post",
|
||||
"actionClassName": "Espo\\Tools\\LeadCapture\\Api\\PostForm",
|
||||
"noAuth": true
|
||||
},
|
||||
{
|
||||
"route": "/LeadCapture/:apiKey",
|
||||
"method": "post",
|
||||
|
||||
@@ -62,23 +62,15 @@ class LanguageService
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getDataForFrontend(bool $default = false): array
|
||||
public function getDataForFrontendFromLanguage(LanguageUtil $language): array
|
||||
{
|
||||
if ($default) {
|
||||
$languageObj = $this->getDefaultLanguage();
|
||||
} else {
|
||||
$languageObj = $this->getLanguage();
|
||||
}
|
||||
|
||||
$data = $languageObj->getAll();
|
||||
$data = $language->getAll();
|
||||
|
||||
if ($this->user->isSystem()) {
|
||||
unset($data['Global']['scopeNames']);
|
||||
unset($data['Global']['scopeNamesPlural']);
|
||||
unset($data['Global']['dashlets']);
|
||||
unset($data['Global']['links']);
|
||||
unset($data['Global']['fields']);
|
||||
unset($data['Global']['options']);
|
||||
|
||||
foreach ($data as $k => $item) {
|
||||
if (
|
||||
@@ -145,20 +137,34 @@ class LanguageService
|
||||
}
|
||||
|
||||
if (!$this->user->isAdmin()) {
|
||||
$this->prepareDataNonAdmin($data, $languageObj);
|
||||
$this->prepareDataNonAdmin($data, $language);
|
||||
}
|
||||
}
|
||||
|
||||
$data['User']['fields'] = $data['User']['fields'] ?? [];
|
||||
|
||||
$data['User']['fields']['password'] = $languageObj->translate('password', 'fields', 'User');
|
||||
$data['User']['fields']['passwordConfirm'] = $languageObj->translate('passwordConfirm', 'fields', 'User');
|
||||
$data['User']['fields']['newPassword'] = $languageObj->translate('newPassword', 'fields', 'User');
|
||||
$data['User']['fields']['newPasswordConfirm'] = $languageObj->translate('newPasswordConfirm', 'fields', 'User');
|
||||
$data['User']['fields']['password'] = $language->translate('password', 'fields', 'User');
|
||||
$data['User']['fields']['passwordConfirm'] = $language->translate('passwordConfirm', 'fields', 'User');
|
||||
$data['User']['fields']['newPassword'] = $language->translate('newPassword', 'fields', 'User');
|
||||
$data['User']['fields']['newPasswordConfirm'] = $language->translate('newPasswordConfirm', 'fields', 'User');
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getDataForFrontend(bool $default = false): array
|
||||
{
|
||||
if ($default) {
|
||||
$languageObj = $this->getDefaultLanguage();
|
||||
} else {
|
||||
$languageObj = $this->getLanguage();
|
||||
}
|
||||
|
||||
return $this->getDataForFrontendFromLanguage($languageObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Captcha;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Entities\Integration;
|
||||
use Espo\ORM\EntityManager;
|
||||
use RuntimeException;
|
||||
|
||||
class Checker
|
||||
{
|
||||
private const URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
private const SCORE_THRESHOLD = 0.2;
|
||||
private const TIMEOUT = 20;
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Log $log,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function check(string $token, string $action): void
|
||||
{
|
||||
[$secret, $scoreThreshold] = $this->getCaptchaSecretKey();
|
||||
|
||||
if ($secret && $token === '') {
|
||||
throw new BadRequest("No captcha token.");
|
||||
}
|
||||
|
||||
if (!$secret) {
|
||||
throw new Forbidden("Captcha not configured.");
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, self::URL);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
|
||||
$data = [
|
||||
'secret' => $secret,
|
||||
'response' => $token,
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if (!is_string($response)) {
|
||||
throw new RuntimeException("Bad CURL response.");
|
||||
}
|
||||
|
||||
$responseData = Json::decode($response, true);
|
||||
|
||||
if (!is_array($responseData)) {
|
||||
throw new RuntimeException("Bad response from ReCaptcha.");
|
||||
}
|
||||
|
||||
$success = $responseData['success'] ?? null;
|
||||
$score = $responseData['score'] ?? null;
|
||||
$resultAction = $responseData['action'] ?? null;
|
||||
|
||||
if (!$success) {
|
||||
$this->log->error("Captcha error; action: {action}; response: {response}", [
|
||||
'action' => $action,
|
||||
'response' => $response,
|
||||
]);
|
||||
|
||||
throw new Forbidden("ReCaptcha error.");
|
||||
}
|
||||
|
||||
if (!is_string($resultAction)) {
|
||||
throw new RuntimeException("No or bad action in ReCaptcha response.");
|
||||
}
|
||||
|
||||
if (!is_int($score) && !is_float($score)) {
|
||||
throw new RuntimeException("No score in ReCaptcha response.");
|
||||
}
|
||||
|
||||
if ($action !== $resultAction) {
|
||||
throw new Forbidden("ReCaptcha action mismatch.");
|
||||
}
|
||||
|
||||
if ($score < $scoreThreshold) {
|
||||
throw new Forbidden("ReCaptcha low score.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{?string, ?int}
|
||||
*/
|
||||
private function getCaptchaSecretKey(): array
|
||||
{
|
||||
$entity = $this->entityManager
|
||||
->getRepositoryByClass(Integration::class)
|
||||
->getById('GoogleReCaptcha');
|
||||
|
||||
if (!$entity) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$secretKey = $entity->get('secretKey');
|
||||
$scoreThreshold = $entity->get('scoreThreshold') ?? self::SCORE_THRESHOLD;
|
||||
|
||||
if (!$secretKey) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [$secretKey, $scoreThreshold];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\LeadCapture\Api;
|
||||
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Tools\LeadCapture\CaptureService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class PostForm implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private CaptureService $service,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$captchaToken = $request->getHeader('X-Captcha-Token');
|
||||
|
||||
$result = $this->service->captureForm($id, $data, $captchaToken);
|
||||
|
||||
return ResponseComposer::json([
|
||||
'redirectUrl' => $result->redirectUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
|
||||
namespace Espo\Tools\LeadCapture;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Field\Link;
|
||||
use Espo\Core\FieldValidation\Exceptions\ValidationError;
|
||||
use Espo\Core\FieldValidation\Failure;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\PhoneNumber\Sanitizer as PhoneNumberSanitizer;
|
||||
use Espo\Core\Job\JobSchedulerFactory;
|
||||
@@ -36,7 +40,7 @@ use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\FieldValidation\FieldValidationManager;
|
||||
use Espo\Core\FieldValidation\FieldValidationParams;
|
||||
use Espo\Core\FieldValidation\FieldValidationParams as ValidationParams;
|
||||
use Espo\Core\HookManager;
|
||||
use Espo\Core\Job\QueueName;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
@@ -47,7 +51,7 @@ use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Entities\UniqueId;
|
||||
use Espo\Entities\LeadCapture as LeadCaptureEntity;
|
||||
use Espo\Entities\LeadCapture;
|
||||
use Espo\Entities\LeadCaptureLogRecord;
|
||||
use Espo\Modules\Crm\Entities\Campaign;
|
||||
use Espo\Modules\Crm\Entities\TargetList;
|
||||
@@ -55,6 +59,7 @@ use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService;
|
||||
use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\Tools\LeadCapture\Jobs\OptInConfirmation;
|
||||
use Espo\Tools\Captcha\Checker as CaptchaChecker;
|
||||
use stdClass;
|
||||
use DateTime;
|
||||
|
||||
@@ -70,9 +75,42 @@ class CaptureService
|
||||
private JobSchedulerFactory $jobSchedulerFactory,
|
||||
private CampaignService $campaignService,
|
||||
private PhoneNumberSanitizer $phoneNumberSanitizer,
|
||||
private ServiceContainer $serviceContainer
|
||||
private ServiceContainer $serviceContainer,
|
||||
private CaptchaChecker $captchaChecker,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Capture a lead from a web form.
|
||||
*
|
||||
* @param string $id A form ID.
|
||||
* @param stdClass $data A payload.
|
||||
* @param ?string $captchaToken A captcha token.
|
||||
* @throws BadRequest
|
||||
* @throws Error
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function captureForm(string $id, stdClass $data, ?string $captchaToken = null): FormResult
|
||||
{
|
||||
$leadCapture = $this->getLeadCaptureByFormId($id);
|
||||
|
||||
$apiKey = $leadCapture->getApiKey();
|
||||
|
||||
if (!$apiKey) {
|
||||
throw new Error("No API key.");
|
||||
}
|
||||
|
||||
if ($leadCapture->hasFormCaptcha()) {
|
||||
$this->captchaChecker->check($captchaToken ?? '', 'leadCaptureSubmit');
|
||||
}
|
||||
|
||||
$this->capture($apiKey, $data);
|
||||
|
||||
return new FormResult(
|
||||
redirectUrl: $leadCapture->getFormSuccessRedirectUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a lead. A main entry method.
|
||||
*
|
||||
@@ -84,18 +122,7 @@ class CaptureService
|
||||
*/
|
||||
public function capture(string $apiKey, stdClass $data): void
|
||||
{
|
||||
/** @var ?LeadCaptureEntity $leadCapture */
|
||||
$leadCapture = $this->entityManager
|
||||
->getRDBRepositoryByClass(LeadCaptureEntity::class)
|
||||
->where([
|
||||
'apiKey' => $apiKey,
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$leadCapture) {
|
||||
throw new NotFound('Api key is not valid.');
|
||||
}
|
||||
$leadCapture = $this->getLeadCapture($apiKey);
|
||||
|
||||
if (!$leadCapture->optInConfirmation()) {
|
||||
$this->proceed($leadCapture, $data);
|
||||
@@ -199,7 +226,7 @@ class CaptureService
|
||||
* @throws Error
|
||||
*/
|
||||
private function proceed(
|
||||
LeadCaptureEntity $leadCapture,
|
||||
LeadCapture $leadCapture,
|
||||
stdClass $data,
|
||||
?string $leadId = null,
|
||||
bool $isLogged = false
|
||||
@@ -307,7 +334,7 @@ class CaptureService
|
||||
(!$isContactOptedIn || !$leadCapture->subscribeToTargetList()) &&
|
||||
$leadCapture->subscribeContactToTargetList()
|
||||
) {
|
||||
$this->hookManager->process(LeadCaptureEntity::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], [
|
||||
$this->hookManager->process(LeadCapture::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], [
|
||||
'targetId' => $contact->getId(),
|
||||
'targetType' => Contact::ENTITY_TYPE,
|
||||
]);
|
||||
@@ -357,7 +384,7 @@ class CaptureService
|
||||
|
||||
if ($toRelateLead || !$leadCapture->subscribeToTargetList()) {
|
||||
$this->hookManager->process(
|
||||
LeadCaptureEntity::ENTITY_TYPE,
|
||||
LeadCapture::ENTITY_TYPE,
|
||||
'afterLeadCapture',
|
||||
$leadCapture,
|
||||
[],
|
||||
@@ -424,12 +451,12 @@ class CaptureService
|
||||
return new ConfirmResult(
|
||||
ConfirmResult::STATUS_EXPIRED,
|
||||
$this->defaultLanguage
|
||||
->translateLabel('optInConfirmationExpired', 'messages', LeadCaptureEntity::ENTITY_TYPE)
|
||||
->translateLabel('optInConfirmationExpired', 'messages', LeadCapture::ENTITY_TYPE)
|
||||
);
|
||||
}
|
||||
|
||||
/** @var ?LeadCaptureEntity $leadCapture */
|
||||
$leadCapture = $this->entityManager->getEntityById(LeadCaptureEntity::ENTITY_TYPE, $leadCaptureId);
|
||||
/** @var ?LeadCapture $leadCapture */
|
||||
$leadCapture = $this->entityManager->getEntityById(LeadCapture::ENTITY_TYPE, $leadCaptureId);
|
||||
|
||||
if (!$leadCapture) {
|
||||
throw new Error("LeadCapture Confirm: LeadCapture not found.");
|
||||
@@ -457,10 +484,9 @@ class CaptureService
|
||||
* @throws BadRequest
|
||||
* @throws Error
|
||||
*/
|
||||
private function getLeadWithPopulatedData(LeadCaptureEntity $leadCapture, stdClass $data): Lead
|
||||
private function getLeadWithPopulatedData(LeadCapture $leadCapture, stdClass $data): Lead
|
||||
{
|
||||
/** @var Lead $lead */
|
||||
$lead = $this->entityManager->getNewEntity(Lead::ENTITY_TYPE);
|
||||
$lead = $this->entityManager->getRDBRepositoryByClass(Lead::class)->getNew();
|
||||
|
||||
$fieldList = $leadCapture->getFieldList();
|
||||
|
||||
@@ -474,21 +500,35 @@ class CaptureService
|
||||
$this->setFields($fieldList, $data, $lead);
|
||||
|
||||
if ($leadCapture->getLeadSource()) {
|
||||
$lead->set('source', $leadCapture->getLeadSource());
|
||||
$lead->setSource($leadCapture->getLeadSource());
|
||||
}
|
||||
|
||||
if ($leadCapture->getCampaignId()) {
|
||||
$lead->set('campaignId', $leadCapture->getCampaignId());
|
||||
$lead->setCampaign(Link::create($leadCapture->getCampaignId()));
|
||||
}
|
||||
|
||||
if ($leadCapture->getTargetTeamId()) {
|
||||
$lead->addLinkMultipleId(Field::TEAMS, $leadCapture->getTargetTeamId());
|
||||
}
|
||||
|
||||
$validationParams = FieldValidationParams::create()->withTypeSkipFieldList('required', $fieldList);
|
||||
$validationParams = ValidationParams::create()->withTypeSkipFieldList('required', $fieldList);
|
||||
|
||||
$this->fieldValidationManager->process($lead, $data, $validationParams);
|
||||
|
||||
foreach ($fieldList as $field) {
|
||||
if (!$leadCapture->isFieldRequired($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notValid = $this->fieldValidationManager->check($lead, $field, 'required', $data, true);
|
||||
|
||||
if (!$notValid) {
|
||||
$failure = new Failure(Lead::ENTITY_TYPE, $field, 'required');
|
||||
|
||||
throw ValidationError::create($failure);
|
||||
}
|
||||
}
|
||||
|
||||
return $lead;
|
||||
}
|
||||
|
||||
@@ -498,7 +538,7 @@ class CaptureService
|
||||
* lead: ?Lead,
|
||||
* }
|
||||
*/
|
||||
private function findLeadDuplicates(LeadCaptureEntity $leadCapture, Lead $lead): array
|
||||
private function findLeadDuplicates(LeadCapture $leadCapture, Lead $lead): array
|
||||
{
|
||||
$duplicate = null;
|
||||
$contact = null;
|
||||
@@ -585,7 +625,7 @@ class CaptureService
|
||||
}
|
||||
|
||||
private function log(
|
||||
LeadCaptureEntity $leadCapture,
|
||||
LeadCapture $leadCapture,
|
||||
Entity $target,
|
||||
stdClass $data,
|
||||
bool $isNew = true
|
||||
@@ -654,7 +694,7 @@ class CaptureService
|
||||
}
|
||||
|
||||
if ($isEmpty) {
|
||||
throw new BadRequest('noRequiredFields');
|
||||
throw new BadRequest('empty');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +704,7 @@ class CaptureService
|
||||
private function sanitizePhoneNumber(
|
||||
array $fieldList,
|
||||
stdClass $data,
|
||||
LeadCaptureEntity $leadCapture
|
||||
LeadCapture $leadCapture
|
||||
): void {
|
||||
|
||||
if (
|
||||
@@ -689,4 +729,44 @@ class CaptureService
|
||||
unset($data->{Field::PHONE_NUMBER . 'IsInvalid'});
|
||||
unset($data->{Field::PHONE_NUMBER . 'IsOptedOut'});
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getLeadCapture(string $apiKey): LeadCapture
|
||||
{
|
||||
$leadCapture = $this->entityManager
|
||||
->getRDBRepositoryByClass(LeadCapture::class)
|
||||
->where([
|
||||
'apiKey' => $apiKey,
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$leadCapture) {
|
||||
throw new NotFound('Form ID is not valid.');
|
||||
}
|
||||
|
||||
return $leadCapture;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getLeadCaptureByFormId(string $id): LeadCapture
|
||||
{
|
||||
$leadCapture = $this->entityManager
|
||||
->getRDBRepositoryByClass(LeadCapture::class)
|
||||
->where([
|
||||
'formId' => $id,
|
||||
'isActive' => true,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$leadCapture) {
|
||||
throw new NotFound('API key is not valid.');
|
||||
}
|
||||
|
||||
return $leadCapture;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\LeadCapture;
|
||||
|
||||
class FormResult
|
||||
{
|
||||
public function __construct(
|
||||
readonly public ?string $redirectUrl,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\LeadCapture;
|
||||
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Address\CountryDataProvider;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\DataCache;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Utils\ThemeManager;
|
||||
use Espo\Entities\Integration;
|
||||
use Espo\Entities\LeadCapture;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\ORM\Defs\EntityDefs;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\App\LanguageService;
|
||||
use RuntimeException;
|
||||
|
||||
class FormService
|
||||
{
|
||||
private const CACHE_KEY_PREFIX = 'leadCaptureForm';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private Language $defaultLanguage,
|
||||
private CountryDataProvider $countryDataProvider,
|
||||
private LanguageService $languageService,
|
||||
private Language\LanguageFactory $languageFactory,
|
||||
private DataCache $dataCache,
|
||||
private ThemeManager $themeManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{LeadCapture, array<string, mixed>, ?string}
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function getData(string $id): array
|
||||
{
|
||||
$leadCapture = $this->getLeadCapture($id);
|
||||
$captchaKey = $this->getCaptchaKey($leadCapture);
|
||||
$captchaScript = $this->getCaptchaScript($captchaKey);
|
||||
|
||||
$data = $this->getDataInternal($leadCapture);
|
||||
|
||||
$data['captchaKey'] = $captchaKey;
|
||||
$data['isDark'] = $this->themeManager->isDark();
|
||||
|
||||
return [$leadCapture, $data, $captchaScript];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getDataInternal(LeadCapture $leadCapture): array
|
||||
{
|
||||
$cacheKey = $this->getCacheKey($leadCapture);
|
||||
|
||||
if ($this->config->get('useCache') && $this->dataCache->has($cacheKey)) {
|
||||
return $this->getFromCache($cacheKey);
|
||||
}
|
||||
|
||||
$data = $this->prepareData($leadCapture);
|
||||
|
||||
$this->dataCache->store($cacheKey, $data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function getRequestUrl(LeadCapture $leadCapture): string
|
||||
{
|
||||
$formId = $leadCapture->getFormId();
|
||||
|
||||
if (!$formId) {
|
||||
throw new RuntimeException("No API key.");
|
||||
}
|
||||
|
||||
return "LeadCapture/form/$formId";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getFieldList(LeadCapture $leadCapture): array
|
||||
{
|
||||
/** @var string[] $allowedTypeList */
|
||||
$allowedTypeList = $this->metadata->get("entityDefs.LeadCapture.fields.fieldList.webFormFieldTypeList") ?? [];
|
||||
|
||||
$entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE);
|
||||
|
||||
$fieldList = [];
|
||||
|
||||
foreach ($leadCapture->getFieldList() as $field) {
|
||||
if (!$entityDefs->hasField($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemDefs = $entityDefs->getField($field);
|
||||
|
||||
if (!in_array($itemDefs->getType(), $allowedTypeList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldList[] = $field;
|
||||
}
|
||||
|
||||
return $fieldList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $fieldList
|
||||
* @param array<string, array<string, mixed>> $languageData
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function getFieldDefs(
|
||||
array $fieldList,
|
||||
LeadCapture $leadCapture,
|
||||
array &$languageData,
|
||||
Language $language
|
||||
): array {
|
||||
|
||||
$entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE);
|
||||
|
||||
$fieldDefs = [];
|
||||
|
||||
foreach ($fieldList as $field) {
|
||||
$fieldDefs[$field] = $this->metadata->get("entityDefs.Lead.fields.$field");
|
||||
|
||||
if (!$fieldDefs[$field]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyFieldDefsItem(
|
||||
$leadCapture,
|
||||
$entityDefs,
|
||||
$field,
|
||||
$fieldDefs,
|
||||
$languageData,
|
||||
$language
|
||||
);
|
||||
}
|
||||
|
||||
return $fieldDefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $fieldList
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function getDetailLayout(array $fieldList): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($fieldList as $field) {
|
||||
$rows[] = [['name' => $field]];
|
||||
}
|
||||
|
||||
return [['rows' => $rows]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $fieldList
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getMetadataFields(array $fieldList): array
|
||||
{
|
||||
$metadataFields = [];
|
||||
|
||||
$entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE);
|
||||
|
||||
foreach ($fieldList as $field) {
|
||||
$type = $entityDefs->getField($field)->getType();
|
||||
|
||||
if (array_key_exists($type, $metadataFields)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadataFields[$type] = $this->metadata->get("fields.$type");
|
||||
}
|
||||
|
||||
return $metadataFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getConfig(): array
|
||||
{
|
||||
$params = [
|
||||
'decimalMark',
|
||||
'thousandSeparator',
|
||||
'phoneNumberInternational',
|
||||
'phoneNumberExtensions',
|
||||
'phoneNumberPreferredCountryList',
|
||||
'defaultCurrency',
|
||||
'currencyList',
|
||||
'currencyDecimalPlaces',
|
||||
'addressFormat',
|
||||
];
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($params as $param) {
|
||||
$data[$param] = $this->config->get($param);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getAppParams(): array
|
||||
{
|
||||
return [
|
||||
'addressCountryData' => $this->countryDataProvider->get(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $fieldDefs
|
||||
* @param array<string, array<string, mixed>> $languageData
|
||||
*/
|
||||
private function applyFieldDefsItem(
|
||||
LeadCapture $leadCapture,
|
||||
EntityDefs $entityDefs,
|
||||
string $field,
|
||||
array &$fieldDefs,
|
||||
array &$languageData,
|
||||
Language $language,
|
||||
): void {
|
||||
|
||||
$fieldDefs[$field]['required'] = $leadCapture->isFieldRequired($field);
|
||||
|
||||
$itDefs = $entityDefs->getField($field);
|
||||
|
||||
$type = $itDefs->getType();
|
||||
|
||||
if ($type === FieldType::ADDRESS) {
|
||||
$subList = [
|
||||
$field . 'Street',
|
||||
$field . 'Country',
|
||||
$field . 'State',
|
||||
$field . 'PostalCode',
|
||||
$field . 'City',
|
||||
];
|
||||
|
||||
foreach ($subList as $susField) {
|
||||
$fieldDefs[$susField] = $this->metadata->get("entityDefs.Lead.fields.$susField");
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === FieldType::EMAIL) {
|
||||
if ($leadCapture->optInConfirmation()) {
|
||||
$fieldDefs[$field]['required'] = true;
|
||||
}
|
||||
|
||||
$fieldDefs[$field]['onlyPrimary'] = true;
|
||||
}
|
||||
|
||||
if ($type === FieldType::PHONE) {
|
||||
$fieldDefs[$field]['onlyPrimary'] = true;
|
||||
}
|
||||
|
||||
if (
|
||||
in_array($type, [
|
||||
FieldType::ENUM,
|
||||
FieldType::MULTI_ENUM,
|
||||
FieldType::ARRAY,
|
||||
FieldType::CHECKLIST,
|
||||
])
|
||||
) {
|
||||
$reference = $itDefs->getParam('optionsReference');
|
||||
|
||||
if ($reference) {
|
||||
[$refEntityType, $refField] = explode('.', $reference);
|
||||
|
||||
$options = $this->entityManager
|
||||
->getDefs()
|
||||
->tryGetEntity($refEntityType)
|
||||
?->tryGetField($refField)
|
||||
?->getParam('options');
|
||||
|
||||
$fieldDefs[$field]['options'] = $options;
|
||||
unset($fieldDefs[$field]['optionsReference']);
|
||||
|
||||
$languageData[Lead::ENTITY_TYPE] ??= [];
|
||||
$languageData[Lead::ENTITY_TYPE]['options'] ??= [];
|
||||
$languageData[Lead::ENTITY_TYPE]['options'][$field] =
|
||||
$language->get("$refEntityType.options.$refField");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getLanguageData(Language $language): array
|
||||
{
|
||||
$data = $this->languageService->getDataForFrontendFromLanguage($language);
|
||||
|
||||
$data[Lead::ENTITY_TYPE] = $language->get(Lead::ENTITY_TYPE);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function getLeadCapture(string $id): LeadCapture
|
||||
{
|
||||
$leadCapture = $this->entityManager
|
||||
->getRDBRepositoryByClass(LeadCapture::class)
|
||||
->where(['formId' => $id])
|
||||
->findOne();
|
||||
|
||||
if (!$leadCapture || !$leadCapture->hasFormEnabled()) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
return $leadCapture;
|
||||
}
|
||||
|
||||
private function getSuccessText(LeadCapture $leadCapture): string
|
||||
{
|
||||
return $leadCapture->getFormSuccessText() ?? $this->defaultLanguage->translateLabel('Posted');
|
||||
}
|
||||
|
||||
private function getCacheKey(LeadCapture $leadCapture): string
|
||||
{
|
||||
return self::CACHE_KEY_PREFIX . '/' . $leadCapture->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getFromCache(string $cacheKey): array
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
return $this->dataCache->get($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function prepareData(LeadCapture $leadCapture): array
|
||||
{
|
||||
$language = $this->getLanguage($leadCapture);
|
||||
|
||||
$languageData = $this->getLanguageData($language);
|
||||
$fieldList = $this->getFieldList($leadCapture);
|
||||
$fieldDefs = $this->getFieldDefs($fieldList, $leadCapture, $languageData, $language);
|
||||
$detailLayout = $this->getDetailLayout($fieldList);
|
||||
$metadataFields = $this->getMetadataFields($fieldList);
|
||||
$successText = $this->getSuccessText($leadCapture);
|
||||
$text = $leadCapture->getFormText();
|
||||
$config = $this->getConfig();
|
||||
$appParams = $this->getAppParams();
|
||||
|
||||
return [
|
||||
'requestUrl' => $this->getRequestUrl($leadCapture),
|
||||
'fieldDefs' => (object) $fieldDefs,
|
||||
'metadata' => [
|
||||
'fields' => (object) $metadataFields,
|
||||
'app' => [
|
||||
'regExpPatterns' => $this->metadata->get("app.regExpPatterns"),
|
||||
],
|
||||
],
|
||||
'detailLayout' => $detailLayout,
|
||||
'language' => $languageData,
|
||||
'successText' => $successText,
|
||||
'text' => $text,
|
||||
'config' => (object) $config,
|
||||
'appParams' => (object) $appParams,
|
||||
];
|
||||
}
|
||||
|
||||
private function getLanguage(LeadCapture $leadCapture): Language
|
||||
{
|
||||
$language = $this->defaultLanguage;
|
||||
|
||||
if ($leadCapture->getFormLanguage()) {
|
||||
$language = $this->languageFactory->create($leadCapture->getFormLanguage());
|
||||
}
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
private function getCaptchaKey(LeadCapture $leadCapture): ?string
|
||||
{
|
||||
if (!$leadCapture->hasFormCaptcha()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$entity = $this->entityManager
|
||||
->getRepositoryByClass(Integration::class)
|
||||
->getById('GoogleReCaptcha');
|
||||
|
||||
if (!$entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$siteKey = $entity->get('siteKey');
|
||||
|
||||
if (!$siteKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $siteKey;
|
||||
}
|
||||
|
||||
private function getCaptchaScript(?string $siteKey): ?string
|
||||
{
|
||||
if (!$siteKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'https://www.google.com/recaptcha/api.js?data-theme=dark&render=' . $siteKey;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ use Espo\Core\Utils\Util;
|
||||
use Espo\Entities\InboundEmail;
|
||||
use Espo\Entities\LeadCapture as LeadCaptureEntity;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use stdClass;
|
||||
|
||||
@@ -71,9 +70,9 @@ class Service
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function generateNewApiKeyForEntity(string $id): Entity
|
||||
public function generateNewApiKeyForEntity(string $id): LeadCaptureEntity
|
||||
{
|
||||
$service = $this->recordServiceContainer->get(LeadCaptureEntity::ENTITY_TYPE);
|
||||
$service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class);
|
||||
|
||||
$entity = $service->getEntity($id);
|
||||
|
||||
@@ -81,7 +80,31 @@ class Service
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$entity->set('apiKey', $this->generateApiKey());
|
||||
$entity->setApiKey($this->generateApiKey());
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
$service->prepareEntityForOutput($entity);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ForbiddenSilent
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function generateNewFormIdForEntity(string $id): LeadCaptureEntity
|
||||
{
|
||||
$service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class);
|
||||
|
||||
$entity = $service->getEntity($id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
$entity->setFormId($this->generateFormId());
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
@@ -95,6 +118,11 @@ class Service
|
||||
return Util::generateApiKey();
|
||||
}
|
||||
|
||||
public function generateFormId(): string
|
||||
{
|
||||
return Util::generateId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return stdClass[]
|
||||
* @throws Forbidden
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
<div>
|
||||
{{#each emailAddressData}}
|
||||
<div class="input-group email-address-block">
|
||||
<div class="input-group email-address-block {{#if ../onlyPrimary}} only-primary {{/if}}">
|
||||
<input
|
||||
type="email"
|
||||
class="form-control email-address{{#if optOut}} text-strikethrough{{/if}}{{#if invalid}} text-danger{{/if}}"
|
||||
value="{{emailAddress}}" autocomplete="espo-{{../name}}"
|
||||
maxlength={{../itemMaxLength}}
|
||||
>
|
||||
{{#unless ../onlyPrimary}}
|
||||
<span class="input-group-btn">
|
||||
<button
|
||||
class="btn btn-default btn-icon email-property{{#if primary}} active{{/if}} hidden"
|
||||
@@ -54,8 +54,15 @@
|
||||
<span class="fas fa-times"></span>
|
||||
</button>
|
||||
</span>
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-default btn-icon" type="button" data-action="addEmailAddress"><span class="fas fa-plus"></span></button>
|
||||
{{#unless onlyPrimary}}
|
||||
<button
|
||||
class="btn btn-default btn-icon"
|
||||
type="button"
|
||||
data-action="addEmailAddress"
|
||||
><span class="fas fa-plus"></span></button>
|
||||
{{/unless}}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<div class="phone-number-block-container">
|
||||
{{#each phoneNumberData}}
|
||||
<div class="input-group phone-number-block">
|
||||
<div class="input-group phone-number-block {{#if ../onlyPrimary}} only-primary {{/if}}">
|
||||
{{#unless ../onlyPrimary}}
|
||||
<span class="input-group-item">
|
||||
<select
|
||||
data-property-type="type"
|
||||
class="form-control radius-left"
|
||||
>{{options ../params.typeList type scope=../scope field=../name}}</select>
|
||||
</span>
|
||||
<span class="input-group-item input-group-item-middle">
|
||||
{{/unless}}
|
||||
<span class="input-group-item input-group-item-middle input-phone-number-item">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control phone-number numeric-text no-margin-shifting {{#if optOut}} text-strikethrough {{/if}} {{#if invalid}} text-danger {{/if}}"
|
||||
@@ -16,6 +18,7 @@
|
||||
maxlength={{../itemMaxLength}}
|
||||
>
|
||||
</span>
|
||||
{{#unless ../onlyPrimary}}
|
||||
<span class="input-group-btn">
|
||||
<button
|
||||
class="btn btn-default btn-icon phone-property{{#if primary}} active{{/if}} hidden"
|
||||
@@ -62,8 +65,15 @@
|
||||
<span class="fas fa-times"></span>
|
||||
</button>
|
||||
</span>
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-default btn-icon" type="button" data-action="addPhoneNumber"><span class="fas fa-plus"></span></button>
|
||||
{{#unless onlyPrimary}}
|
||||
<button
|
||||
class="btn btn-default btn-icon"
|
||||
type="button"
|
||||
data-action="addPhoneNumber"
|
||||
><span class="fas fa-plus"></span></button>
|
||||
{{/unless}}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import Controller from 'controller';
|
||||
import LeadCaptureFormView from 'views/lead-capture/form';
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default class LeadCaptureFormController extends Controller {
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
actionShow(data) {
|
||||
this.prepareContainer();
|
||||
|
||||
const view = new LeadCaptureFormView({formData: data});
|
||||
view.setSelector('body > .content');
|
||||
|
||||
this.viewFactory.prepare(view, () => view.render());
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
prepareContainer() {
|
||||
// Prevents recaptcha removal.
|
||||
|
||||
const existingContainer = document.body.querySelector('.container');
|
||||
existingContainer.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.classList.add('container', 'content');
|
||||
|
||||
document.body.prepend(container);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,17 @@ class Language {
|
||||
|
||||
return pointer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not use.
|
||||
*
|
||||
* @param {string} [scope]
|
||||
* @param {Record} [data]
|
||||
* @internal
|
||||
*/
|
||||
setScopeData(scope, data) {
|
||||
this.data[scope] = data;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(Language.prototype, Events);
|
||||
|
||||
@@ -259,6 +259,16 @@ class Metadata {
|
||||
|
||||
return scopeList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not use.
|
||||
*
|
||||
* @internal
|
||||
* @param {Record} data
|
||||
*/
|
||||
setData(data) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(Metadata.prototype, Events);
|
||||
|
||||
@@ -613,6 +613,11 @@ class ArrayFieldView extends BaseFieldView {
|
||||
return list.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {string} value
|
||||
* @return {string}
|
||||
*/
|
||||
getItemHtml(value) {
|
||||
// Do not use the `html` method to avoid XSS.
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class EmailFieldView extends VarcharFieldView {
|
||||
/**
|
||||
* @typedef {Object} module:views/fields/email~params
|
||||
* @property {boolean} [required] Required.
|
||||
* @property {boolean} [onlyPrimary] Only primary.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -270,7 +271,7 @@ class EmailFieldView extends VarcharFieldView {
|
||||
|
||||
validateMaxCount() {
|
||||
/** @type {number|null} */
|
||||
const maxCount = this.getConfig().get('emailAddressMaxCount');
|
||||
const maxCount = this.maxCount;
|
||||
|
||||
if (!maxCount) {
|
||||
return false;
|
||||
@@ -355,6 +356,7 @@ class EmailFieldView extends VarcharFieldView {
|
||||
}
|
||||
|
||||
data.itemMaxLength = this.itemMaxLength;
|
||||
data.onlyPrimary = this.params.onlyPrimary;
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -610,6 +612,7 @@ class EmailFieldView extends VarcharFieldView {
|
||||
this.erasedPlaceholder = 'ERASED:';
|
||||
|
||||
this.emailAddressOptedOutByDefault = this.getConfig().get('emailAddressIsOptedOutByDefault');
|
||||
this.maxCount = this.getConfig().get('emailAddressMaxCount');
|
||||
|
||||
this.itemMaxLength = this.getMetadata()
|
||||
.get(['entityDefs', 'EmailAddress', 'fields', 'name', 'maxLength']) || 255;
|
||||
@@ -617,6 +620,15 @@ class EmailFieldView extends VarcharFieldView {
|
||||
this.validations.push(() => this.validateMaxCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {{
|
||||
* emailAddress: string,
|
||||
* primary: boolean,
|
||||
* optOut: boolean,
|
||||
* invalid: boolean,
|
||||
* lower: string,
|
||||
* }[]}
|
||||
*/
|
||||
fetchEmailAddressData() {
|
||||
const data = [];
|
||||
|
||||
@@ -649,7 +661,27 @@ class EmailFieldView extends VarcharFieldView {
|
||||
fetch() {
|
||||
const data = {};
|
||||
|
||||
const addressData = this.fetchEmailAddressData() || [];
|
||||
const addressData = this.fetchEmailAddressData();
|
||||
|
||||
if (this.params.onlyPrimary) {
|
||||
if (addressData.length > 0) {
|
||||
data[this.name] = addressData[0].emailAddress;
|
||||
|
||||
data[this.dataFieldName] = [
|
||||
{
|
||||
emailAddress: addressData[0].emailAddress,
|
||||
lower: addressData[0].lower,
|
||||
primary: true,
|
||||
}
|
||||
];
|
||||
} else {
|
||||
data[this.name] = null;
|
||||
data[this.dataFieldName] = null;
|
||||
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
data[this.dataFieldName] = addressData;
|
||||
data[this.name] = null;
|
||||
|
||||
@@ -53,6 +53,7 @@ class PhoneFieldView extends VarcharFieldView {
|
||||
/**
|
||||
* @typedef {Object} module:views/fields/phone~params
|
||||
* @property {boolean} [required] Required.
|
||||
* @property {boolean} [onlyPrimary] Only primary.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -266,7 +267,7 @@ class PhoneFieldView extends VarcharFieldView {
|
||||
|
||||
validateMaxCount() {
|
||||
/** @type {number|null} */
|
||||
const maxCount = this.getConfig().get('phoneNumberMaxCount');
|
||||
const maxCount = this.maxCount;
|
||||
|
||||
if (!maxCount) {
|
||||
return false;
|
||||
@@ -453,6 +454,7 @@ class PhoneFieldView extends VarcharFieldView {
|
||||
}
|
||||
|
||||
data.itemMaxLength = this.itemMaxLength;
|
||||
data.onlyPrimary = this.params.onlyPrimary;
|
||||
|
||||
// noinspection JSValidateTypes
|
||||
return data;
|
||||
@@ -655,6 +657,7 @@ class PhoneFieldView extends VarcharFieldView {
|
||||
this.useInternational = this.getConfig().get('phoneNumberInternational') || false;
|
||||
this.allowExtensions = this.getConfig().get('phoneNumberExtensions') || false;
|
||||
this.preferredCountryList = this.getConfig().get('phoneNumberPreferredCountryList') || [];
|
||||
this.maxCount = this.getConfig().get('phoneNumberMaxCount');
|
||||
|
||||
if (this.useInternational && !this.isListMode() && !this.isSearchMode()) {
|
||||
this._codeNames = intlTelInputGlobals.getCountryData()
|
||||
@@ -754,7 +757,25 @@ class PhoneFieldView extends VarcharFieldView {
|
||||
fetch() {
|
||||
const data = {};
|
||||
|
||||
const addressData = this.fetchPhoneNumberData() || [];
|
||||
const addressData = this.fetchPhoneNumberData();
|
||||
|
||||
if (this.params.onlyPrimary) {
|
||||
if (addressData.length > 0) {
|
||||
data[this.name] = addressData[0].phoneNumber;
|
||||
|
||||
data[this.dataFieldName] = [
|
||||
{
|
||||
phoneNumber: addressData[0].phoneNumber,
|
||||
primary: true,
|
||||
}
|
||||
];
|
||||
} else {
|
||||
data[this.name] = null;
|
||||
data[this.dataFieldName] = null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
data[this.dataFieldName] = addressData;
|
||||
data[this.name] = null;
|
||||
|
||||
@@ -26,9 +26,74 @@
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import MultiEnumFieldView from 'views/fields/multi-enum';
|
||||
import ArrayFieldView from 'views/fields/array';
|
||||
|
||||
export default class extends MultiEnumFieldView {
|
||||
export default class extends ArrayFieldView {
|
||||
|
||||
// language=Handlebars
|
||||
detailTemplateContent = `
|
||||
{{#unless isEmpty}}
|
||||
{{#each items}}
|
||||
<div
|
||||
class="multi-enum-item-container"
|
||||
style="{{#if strikeThrough}} text-decoration: line-through; {{/if}}"
|
||||
>{{label}}{{#if required}} *{{/if}}</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{#if valueIsSet}}<span class="none-value">{{translate 'None'}}</span>
|
||||
{{else}}
|
||||
<span class="loading-value"></span>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
`
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {string[]}
|
||||
*/
|
||||
webFormNotAllowedFields
|
||||
|
||||
data() {
|
||||
/** @type {string[]|null} */
|
||||
const items = this.model.get(this.name);
|
||||
|
||||
if (!items) {
|
||||
return super.data();
|
||||
}
|
||||
|
||||
const dataItems = items.map(it => {
|
||||
return {
|
||||
label: this.translatedOptions[it] || it,
|
||||
strikeThrough: this.model.attributes.formEnabled && this.webFormNotAllowedFields.includes(it),
|
||||
required: this.isFieldRequired(it),
|
||||
};
|
||||
})
|
||||
|
||||
return {
|
||||
...super.data(),
|
||||
items: dataItems,
|
||||
};
|
||||
}
|
||||
|
||||
getAttributeList() {
|
||||
return [...super.getAttributeList(), 'formEnabled', 'fieldParams'];
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.webFormNotAllowedFields = [];
|
||||
|
||||
super.setup();
|
||||
|
||||
this.listenTo(this.model, 'change:formEnabled', (m, v, o) => {
|
||||
if (!o.ui || !this.isDetailMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reRender();
|
||||
});
|
||||
|
||||
this.addActionHandler('toggleRequired', (e, target) => this.toggleRequired(target.dataset.value));
|
||||
}
|
||||
|
||||
setupOptions() {
|
||||
this.params.options = [];
|
||||
@@ -39,7 +104,11 @@ export default class extends MultiEnumFieldView {
|
||||
|
||||
/** @type {string[]} */
|
||||
const ignoreFieldList = this.getMetadata()
|
||||
.get(['entityDefs', 'LeadCapture', 'fields', 'fieldList', 'ignoreFieldList']) || [];
|
||||
.get(`entityDefs.LeadCapture.fields.fieldList.ignoreFieldList`) || [];
|
||||
|
||||
/** @type {string[]} */
|
||||
const webFormTypeList = this.getMetadata()
|
||||
.get(`entityDefs.LeadCapture.fields.fieldList.webFormFieldTypeList`) || [];
|
||||
|
||||
for (const field in fields) {
|
||||
const defs = fields[field];
|
||||
@@ -52,8 +121,107 @@ export default class extends MultiEnumFieldView {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!webFormTypeList.includes(defs.type)) {
|
||||
this.webFormNotAllowedFields.push(field);
|
||||
}
|
||||
|
||||
this.params.options.push(field);
|
||||
this.translatedOptions[field] = this.translate(field, 'fields', 'Lead');
|
||||
}
|
||||
}
|
||||
|
||||
getItemHtml(value) {
|
||||
const html = super.getItemHtml(value);
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const item = div.querySelector('.list-group-item');
|
||||
|
||||
const group = document.createElement('div');
|
||||
group.classList.add('btn-group', 'pull-right');
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.classList.add('btn', 'btn-link', 'btn-sm', 'dropdown-toggle');
|
||||
button.innerHTML = `<span class="caret"></span>`;
|
||||
button.dataset.toggle = 'dropdown';
|
||||
button.type = 'button';
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.classList.add('dropdown-menu', 'pull-right');
|
||||
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.dataset.value = value;
|
||||
a.dataset.action = 'toggleRequired';
|
||||
|
||||
a.role = 'button';
|
||||
a.tabIndex = 0;
|
||||
|
||||
if (this.isFieldRequired(value)) {
|
||||
a.innerHTML += `<span class="check-icon fas fa-check pull-right"></span>`;
|
||||
}
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.textContent = this.translate('required', 'fields', 'Admin');
|
||||
a.append(textDiv);
|
||||
|
||||
li.append(a);
|
||||
|
||||
ul.append(li);
|
||||
|
||||
group.append(button, ul);
|
||||
item.append(group);
|
||||
|
||||
if (this.isFieldRequired(value)) {
|
||||
const text = div.querySelector('.text');
|
||||
|
||||
if (text) {
|
||||
text.innerHTML += ' *';
|
||||
}
|
||||
}
|
||||
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} field
|
||||
* @return {boolean}
|
||||
*/
|
||||
isFieldRequired(field) {
|
||||
const params = this.model.attributes.fieldParams || {};
|
||||
const fieldParams = params[field] || {};
|
||||
|
||||
return !!fieldParams.required;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} field
|
||||
*/
|
||||
toggleRequired(field) {
|
||||
const params = Espo.Utils.cloneDeep(this.model.attributes.fieldParams || {});
|
||||
|
||||
if (!params[field]) {
|
||||
params[field] = {};
|
||||
}
|
||||
|
||||
if (!('required' in params[field])) {
|
||||
params[field].required = false;
|
||||
}
|
||||
|
||||
params[field].required = !params[field].required;
|
||||
|
||||
const newParams = {};
|
||||
|
||||
/** @type {string[]} */
|
||||
const fieldList = this.model.attributes.fieldList || [];
|
||||
|
||||
fieldList.forEach(it => newParams[it] = params[it]);
|
||||
|
||||
this.model.set('fieldParams', newParams, {ui: true});
|
||||
|
||||
this.reRender();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import View from 'view';
|
||||
import EditRecordView from 'views/record/edit';
|
||||
import Model from 'model';
|
||||
|
||||
export default class LeadCaptureFormView extends View {
|
||||
|
||||
// language=Handlebars
|
||||
templateContent = `
|
||||
<div class="block-center-md">
|
||||
{{#if isPosted}}
|
||||
<div class="panel paned-default">
|
||||
<div class="panel-body">
|
||||
<div class="complex-text">{{complexText successText}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if text}}
|
||||
<div class="panel paned-default">
|
||||
<div class="panel-body">
|
||||
<div class="complex-text">{{complexText text}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="record">{{{record}}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
`
|
||||
|
||||
isPosted = false
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* formData: {
|
||||
* requestUrl: string,
|
||||
* detailLayout: module:views/record/detail~panelDefs[],
|
||||
* fieldDefs: Record,
|
||||
* metadata: Record,
|
||||
* language: Record.<string, Record>,
|
||||
* successText: string,
|
||||
* text: string|null,
|
||||
* config: Record,
|
||||
* appParams: Record,
|
||||
* captchaKey: boolean,
|
||||
* isDark: boolean,
|
||||
* },
|
||||
* }} options
|
||||
*/
|
||||
constructor(options) {
|
||||
super();
|
||||
|
||||
this.formData = options.formData;
|
||||
}
|
||||
|
||||
data() {
|
||||
return {
|
||||
isPosted: this.isPosted,
|
||||
successText: this.formData.successText,
|
||||
text: this.formData.text,
|
||||
};
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.getMetadata().setData(this.formData.metadata);
|
||||
this.getConfig().setMultiple(this.formData.config);
|
||||
this.getHelper().appParams.setAll(this.formData.appParams);
|
||||
this.getHelper().fieldManager.defs = this.getMetadata().get('fields');
|
||||
|
||||
if (this.formData.captchaKey) {
|
||||
// noinspection JSUnresolvedReference
|
||||
grecaptcha.ready(() => {
|
||||
// noinspection SpellCheckingInspection
|
||||
/** @type {HTMLElement|null} */
|
||||
const badge = document.querySelector('.grecaptcha-badge');
|
||||
|
||||
if (badge) {
|
||||
badge.style.zIndex = '4';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const it in this.formData.language) {
|
||||
this.getLanguage().setScopeData(it, this.formData.language[it]);
|
||||
}
|
||||
|
||||
this.model = new Model({}, {
|
||||
defs: {fields: this.formData.fieldDefs},
|
||||
entityType: 'Lead',
|
||||
});
|
||||
|
||||
this.model.url = this.formData.requestUrl;
|
||||
|
||||
this.recordView = new EditRecordView({
|
||||
model: this.model,
|
||||
detailLayout: this.formData.detailLayout,
|
||||
buttonList: [
|
||||
{
|
||||
name: 'save',
|
||||
text: this.translate('Submit'),
|
||||
style: 'primary',
|
||||
onClick: () => this.actionCreate(),
|
||||
}
|
||||
],
|
||||
sideView: null,
|
||||
bottomView: null,
|
||||
isWide: true,
|
||||
shortcutKeysEnabled: true,
|
||||
});
|
||||
|
||||
this.assignView('record', this.recordView, '.record');
|
||||
}
|
||||
|
||||
async actionCreate() {
|
||||
if (this.recordView.validate()) {
|
||||
Espo.Ui.error(this.translate('Not valid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordView.disableActionItems();
|
||||
|
||||
Espo.Ui.notify(' ... ')
|
||||
|
||||
const token = await this.processCaptcha();
|
||||
|
||||
const headers = token ? {'X-Captcha-Token': token} : undefined;
|
||||
|
||||
/** @type {{redirectUrl: string|null}} */
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = await Espo.Ajax.postRequest(this.model.url, this.model.attributes, {headers: headers});
|
||||
} catch (e) {
|
||||
this.recordView.enableActionItems();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Espo.Ui.notify();
|
||||
this.isPosted = true;
|
||||
|
||||
this.recordView.remove();
|
||||
|
||||
await this.reRender();
|
||||
|
||||
if (result.redirectUrl) {
|
||||
document.location.href = result.redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
async processCaptcha() {
|
||||
if (!this.formData.captchaKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
// noinspection JSUnresolvedReference
|
||||
grecaptcha.ready(async () => {
|
||||
// noinspection JSUnresolvedReference
|
||||
const token = await grecaptcha.execute(this.formData.captchaKey, {action: 'leadCaptureSubmit'});
|
||||
|
||||
resolve(token);
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ export default class extends DetailRecordView {
|
||||
name: 'generateNewApiKey',
|
||||
onClick: () => this.actionGenerateNewApiKey(),
|
||||
});
|
||||
|
||||
this.addDropdownItem({
|
||||
label: 'Generate New Form ID',
|
||||
name: 'generateNewFormId',
|
||||
onClick: () => this.actionGenerateNewFormId(),
|
||||
});
|
||||
}
|
||||
|
||||
actionGenerateNewApiKey() {
|
||||
@@ -45,7 +51,19 @@ export default class extends DetailRecordView {
|
||||
Espo.Ajax.postRequest('LeadCapture/action/generateNewApiKey', {id: this.model.id})
|
||||
.then(data => {
|
||||
this.model.set(data);
|
||||
|
||||
Espo.Ui.success(this.translate('Done'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async actionGenerateNewFormId() {
|
||||
await this.confirm(this.translate('confirmation', 'messages'));
|
||||
|
||||
const data = await Espo.Ajax.postRequest('LeadCapture/action/generateNewFormId', {id: this.model.id});
|
||||
|
||||
this.model.set(data);
|
||||
|
||||
Espo.Ui.success(this.translate('Done'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import SidePanelView from 'views/record/panels/side';
|
||||
|
||||
export default class extends SidePanelView {
|
||||
|
||||
fieldList = [
|
||||
'formUrl'
|
||||
]
|
||||
}
|
||||
@@ -52,8 +52,8 @@ class DetailRecordView extends BaseRecordView {
|
||||
* @property {boolean} [editModeDisabled]
|
||||
* @property {boolean} [confirmLeaveDisabled]
|
||||
* @property {boolean} [isWide]
|
||||
* @property {string} [sideView]
|
||||
* @property {string} [bottomView]
|
||||
* @property {string|null} [sideView]
|
||||
* @property {string|null} [bottomView]
|
||||
* @property {string} [inlineEditDisabled] Disable inline edit.
|
||||
* @property {boolean} [buttonsDisabled] Disable buttons.
|
||||
* @property {string} [navigateButtonsDisabled]
|
||||
@@ -64,6 +64,7 @@ class DetailRecordView extends BaseRecordView {
|
||||
* @property {module:views/record/detail~dropdownItem[]} [dropdownItemList] Dropdown items.
|
||||
* @property {Object.<string, *>} [dataObject] Additional data.
|
||||
* @property {Record} [rootData] Data from the root view.
|
||||
* @property {boolean} [shortcutKeysEnabled] Enable shortcut keys.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -3858,6 +3858,18 @@ body:has(.modal-backdrop) {
|
||||
}
|
||||
}
|
||||
|
||||
.email-address-block.only-primary,
|
||||
.phone-number-block.only-primary {
|
||||
input.form-control {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-group-currency {
|
||||
display: flex;
|
||||
|
||||
@@ -3925,7 +3937,7 @@ body > .autocomplete-suggestions.text-search-suggestions {
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-item > .iti {
|
||||
.input-phone-number-item > .iti {
|
||||
//top: 3px;
|
||||
position: unset;
|
||||
display: block;
|
||||
|
||||
@@ -390,11 +390,13 @@ body:not([data-navbar="side"]) {
|
||||
}
|
||||
}
|
||||
|
||||
.stick-sub {
|
||||
margin-top: var(--navbar-height);
|
||||
&:has(> #header > #navbar) {
|
||||
.stick-sub {
|
||||
margin-top: var(--navbar-height);
|
||||
|
||||
@media screen and (max-width: @screen-xs-max) {
|
||||
margin-top: 0;
|
||||
@media screen and (max-width: @screen-xs-max) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user