Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
<?php
$config = new PrestaShop\CodingStandards\CsFixer\Config();
/** @var \Symfony\Component\Finder\Finder $finder */
$finder = $config->setUsingCache(true)->getFinder();
$finder->in(__DIR__)->exclude('vendor');
return $config;

View File

@@ -0,0 +1,2 @@
clean-build:
rm -fR vue-app

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<module>
<name>ps_distributionapiclient</name>
<displayName><![CDATA[Distribution API Client]]></displayName>
<version><![CDATA[2.1.1]]></version>
<description><![CDATA[Download and upgrade PrestaShop&#039;s native modules.]]></description>
<author><![CDATA[PrestaShop]]></author>
<tab><![CDATA[market_place]]></tab>
<is_configurable>0</is_configurable>
<need_instance>1</need_instance>
</module>

View File

@@ -0,0 +1,7 @@
ps_distributionapiclient_top_contributors:
path: /ps_distributionapiclient/top-contributors
methods: [GET]
defaults:
_controller: 'PrestaShop\Module\DistributionApiClient\Controller\Admin\TopContributorsController::index'
_legacy_controller: 'AdminPsdistributionapiclient'
_legacy_link: 'AdminPsdistributionapiclient'

View File

@@ -0,0 +1,65 @@
parameters:
ps_cache_dir: !php/const _PS_CACHE_DIR_
services:
distributionapiclient.cache.filesystem.adapter:
class: Symfony\Component\Cache\Adapter\FilesystemAdapter
arguments:
- ''
- !php/const PrestaShop\Module\DistributionApiClient\DistributionApi::CACHE_LIFETIME_SECONDS
- '%ps_cache_dir%/distribution-api'
distributionapiclient.cache.provider:
class: Doctrine\Common\Cache\Psr6\DoctrineProvider
factory: [ Doctrine\Common\Cache\Psr6\DoctrineProvider, wrap ]
arguments:
- '@distributionapiclient.cache.filesystem.adapter'
distributionapiclient.middleware.cachedhttpclient:
class: PrestaShop\Module\DistributionApiClient\Middleware\CachedHttpClient
arguments:
$cache: '@distributionapiclient.cache.provider'
distributionapiclient.symfony.client:
class: PrestaShop\CircuitBreaker\Client\SymfonyHttpClient
arguments:
$client: '@distributionapiclient.middleware.cachedhttpclient'
distributionapiclient.circuit_breaker.factory:
class: PrestaShop\CircuitBreaker\AdvancedCircuitBreakerFactory
distributionapiclient.circuit_breaker.settings:
class: PrestaShop\CircuitBreaker\FactorySettings
arguments:
- !php/const PrestaShop\Module\DistributionApiClient\DistributionApi::ALLOWED_FAILURES
- !php/const PrestaShop\Module\DistributionApiClient\DistributionApi::TIMEOUT_IN_SECONDS
- !php/const PrestaShop\Module\DistributionApiClient\DistributionApi::THRESHOLD_SECONDS
calls:
- setStorage: [ '@prestashop.core.circuit_breaker.storage' ]
- setClient: [ '@distributionapiclient.symfony.client' ]
distributionapiclient.circuit_breaker:
class: PrestaShop\CircuitBreaker\Contract\CircuitBreakerInterface
factory: [ '@distributionapiclient.circuit_breaker.factory', 'create' ]
arguments: [ '@distributionapiclient.circuit_breaker.settings' ]
PrestaShop\Module\DistributionApiClient\ShopDataProvider:
class: PrestaShop\Module\DistributionApiClient\ShopDataProvider
distributionapiclient.distribution_api:
class: PrestaShop\Module\DistributionApiClient\DistributionApi
arguments:
- '@distributionapiclient.circuit_breaker'
- '@prestashop.module.factory.sourcehandler'
- '@prestashop.adapter.data_provider.module'
- '@PrestaShop\Module\DistributionApiClient\ShopDataProvider'
- "@=service('prestashop.core.foundation.version').getSemVersion()"
- '%ps_cache_dir%/downloads'
- '%kernel.project_dir%'
public: true
PrestaShop\Module\DistributionApiClient\Controller\Admin\TopContributorsController:
autowire: true
autoconfigure: true
public: true
tags: ['controller.service_arguments']

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,139 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
declare(strict_types=1);
use PrestaShop\Module\DistributionApiClient\DistributionApi;
if (!defined('_PS_VERSION_')) {
exit;
}
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
class Ps_Distributionapiclient extends Module
{
public function __construct()
{
$this->name = 'ps_distributionapiclient';
$this->displayName = $this->trans('Distribution API Client', [], 'Modules.Distributionapiclient.Admin');
$this->description = $this->trans('Download and upgrade PrestaShop\'s native modules.', [], 'Modules.Distributionapiclient.Admin');
$this->author = 'PrestaShop';
$this->version = '2.1.1';
$this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => _PS_VERSION_];
$this->tab = 'market_place';
parent::__construct();
}
public function install(): bool
{
return parent::install()
&& $this->registerHook('actionListModules')
&& $this->registerHook('actionBeforeInstallModule')
&& $this->registerHook('actionBeforeUpgradeModule')
&& $this->registerTab()
;
}
/**
* @return array<array<string, string>>
*/
public function hookActionListModules(): array
{
return $this->getDistributionApi()->getModuleList();
}
/**
* @param string[] $params
*
* @return void
*/
public function hookActionBeforeInstallModule(array $params): void
{
$distributionApi = $this->getDistributionApi();
if (!isset($params['moduleName']) || $distributionApi->isModuleOnDisk($params['moduleName'])) {
return;
}
$distributionApi->downloadModule($params['moduleName']);
}
/**
* @param string[] $params
*
* @return void
*/
public function hookActionBeforeUpgradeModule(array $params): void
{
if (!isset($params['moduleName']) || !empty($params['source'])) {
return;
}
$this->getDistributionApi()->downloadModule($params['moduleName']);
}
private function getDistributionApi(): DistributionApi
{
/** @var DistributionApi $distributionApi */
$distributionApi = $this->get('distributionapiclient.distribution_api');
return $distributionApi;
}
public function registerTab(): bool
{
$parentClass = 'AdminPsdistributionapiclientCommunity';
$parentTabId = Tab::getIdFromClassName($parentClass);
$parentTab = new Tab($parentTabId ?: null);
$parentTab->active = true;
$parentTab->class_name = $parentClass;
$parentTab->id_parent = 0;
$parentTab->module = $this->name;
$parentTab->wording = 'Community';
$parentTab->wording_domain = 'Modules.Distributionapiclient.Admin';
/** @var array{'id_lang': int, "locale": string} $lang */
foreach (Language::getLanguages() as $lang) {
$parentTab->name[$lang['id_lang']] = $this->trans('Community', [], 'Modules.Distributionapiclient.Admin', $lang['locale']);
}
$parentTab->save();
// Creation of the sub tab "Wall of Fame"
$childClass = 'AdminPsdistributionapiclient';
$childTabId = Tab::getIdFromClassName($childClass);
$childTab = new Tab($childTabId ?: null);
$childTab->active = true;
$childTab->class_name = $childClass;
$childTab->id_parent = (int) Tab::getIdFromClassName($parentClass);
$childTab->route_name = 'ps_distributionapiclient_top_contributors';
$childTab->module = $this->name;
$childTab->wording = 'Wall of Fame';
$childTab->wording_domain = 'Modules.Distributionapiclient.Admin';
$childTab->icon = 'groups';
/** @var array{'id_lang': int, "locale": string} $lang */
foreach (Language::getLanguages() as $lang) {
$childTab->name[$lang['id_lang']] = $this->trans('Wall of Fame', [], 'Modules.Distributionapiclient.Admin', $lang['locale']);
}
$childTab->save();
return true;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace PrestaShop\Module\DistributionApiClient\Controller\Admin;
use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class TopContributorsController extends PrestaShopAdminController
{
/**
* @Route("/ps_distributionapiclient/top-contributors", name="ps_distributionapiclient_top_contributors")
*/
public function index(): Response
{
return $this->render('@Modules/ps_distributionapiclient/views/templates/admin/top_contributors.html.twig', [
'enableSidebar' => false,
'showContentHeader' => true,
]);
}
}

View File

@@ -0,0 +1,240 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
declare(strict_types=1);
namespace PrestaShop\Module\DistributionApiClient;
use PrestaShop\CircuitBreaker\Contract\CircuitBreakerInterface;
use PrestaShop\PrestaShop\Adapter\Module\ModuleDataProvider;
use PrestaShop\PrestaShop\Core\Module\SourceHandler\SourceHandlerFactory;
use RuntimeException;
class DistributionApi
{
public const ALLOWED_FAILURES = 2;
public const TIMEOUT_IN_SECONDS = 3;
public const THRESHOLD_SECONDS = 86400; // 24 hours
public const CACHE_LIFETIME_SECONDS = 86400; // 24 hours
public const URL_TRACKING_ENV_NAME = 'PS_URL_TRACKING';
private const API_ENDPOINT = 'https://api.prestashop-project.org';
/** @var CircuitBreakerInterface */
private $circruitBreaker;
/** @var SourceHandlerFactory */
private $sourceHandlerFactory;
/** @var ModuleDataProvider */
private $moduleDataProvider;
/** @var string */
private $prestashopVersion;
/** @var string */
private $downloadDirectory;
/** @var ShopDataProvider */
private $shopDataProvider;
/** @var string */
private $projectDirectory;
public function __construct(
CircuitBreakerInterface $circruitBreaker,
SourceHandlerFactory $sourceHandlerFactory,
ModuleDataProvider $moduleDataProvider,
ShopDataProvider $shopDataProvider,
string $prestashopVersion,
string $downloadDirectory,
string $projectDirectory,
) {
$this->circruitBreaker = $circruitBreaker;
$this->sourceHandlerFactory = $sourceHandlerFactory;
$this->moduleDataProvider = $moduleDataProvider;
$this->prestashopVersion = $prestashopVersion;
$this->downloadDirectory = rtrim($downloadDirectory, '/');
$this->shopDataProvider = $shopDataProvider;
$this->projectDirectory = $projectDirectory;
}
/**
* @return array<array<string, string>>
*/
public function getModuleList(): array
{
$endpoint = $this->getModulesListUrl();
$response = $this->getResponse($endpoint);
$modules = [];
foreach ($response as $name => $module) {
$attributes = [
'name' => $name,
'version_available' => $module['version'],
'download_url' => $module['download_url'],
];
if (!$this->isModuleOnDisk($name)) {
$attributes += [
'displayName' => $module['display_name'],
'description' => $module['description'],
'version' => $module['version'],
'author' => $module['author'],
'img' => $module['icon'],
'tab' => $module['tab'],
];
}
$modules[] = $attributes;
}
return $modules;
}
public function downloadModule(string $moduleName): void
{
$modules = $this->getModuleList();
foreach ($modules as $module) {
if ($module['name'] === $moduleName) {
$this->doDownload($module);
break;
}
}
}
public function isModuleOnDisk(string $moduleName): bool
{
return $this->moduleDataProvider->isOnDisk($moduleName);
}
/**
* Extracts the download URL from a module data structure
*
* @param array{download_url?: string} $module Module data structure, from API response
*
* @return string Download URL
*/
protected function getModuleDownloadUrl(array $module): string
{
if (!isset($module['download_url'])) {
throw new RuntimeException('Could not determine URL to download the module from');
}
return $this->addShopInfoToUrl($module['download_url']);
}
/**
* Returns the URL to the list of modules for this version
*
* @return string
*/
private function getModulesListUrl(): string
{
$url = self::API_ENDPOINT . '/modules/' . $this->prestashopVersion;
return $this->addShopInfoToUrl($url);
}
/**
* Adds shop information to an URL
*
* @param string $url API endpoint
*
* @return string Modified URL
*/
private function addShopInfoToUrl(string $url): string
{
if (isset($_SERVER[self::URL_TRACKING_ENV_NAME])
&& ((bool) $_SERVER[self::URL_TRACKING_ENV_NAME] === false || $_SERVER[self::URL_TRACKING_ENV_NAME] === 'false')
) {
return $url;
}
$separator = (strpos($url, '?') !== false) ? '&' : '?';
// Add shop URL
$shopUrl = urlencode($this->shopDataProvider->getShopUrl());
$url = sprintf('%s%sshop_domain=%s', $url, $separator, $shopUrl);
// Add distribution details
$metadataFile = $this->projectDirectory . '/app/metadata.json';
if (file_exists($metadataFile)) {
$metadataFileContent = file_get_contents($metadataFile);
if (!empty($metadataFileContent)) {
/** @var array<string, string>|false $metadata */
$metadata = json_decode($metadataFileContent, true);
if (!empty($metadata['distribution']) && !empty($metadata['distributionVersion'])) {
$url = sprintf('%s&distribution=%s&distribution_version=%s', $url, $metadata['distribution'], $metadata['distributionVersion']);
}
}
}
return $url;
}
/**
* @param array<string, string> $module
*
* @return void
*/
private function doDownload(array $module): void
{
$downloadUrl = $this->getModuleDownloadUrl($module);
$moduleZip = file_get_contents($downloadUrl);
$downloadPath = $this->getModuleDownloadDirectory($module['name']);
$this->createDownloadDirectoryIfNeeded($downloadPath);
file_put_contents($this->getModuleDownloadDirectory($module['name']), $moduleZip);
$handler = $this->sourceHandlerFactory->getHandler($this->getModuleDownloadDirectory($module['name']));
$handler->handle($this->getModuleDownloadDirectory($module['name']));
}
private function getModuleDownloadDirectory(string $moduleName): string
{
return $this->downloadDirectory . '/' . $moduleName . '.zip';
}
private function createDownloadDirectoryIfNeeded(string $downloadPath): void
{
if (!file_exists(dirname($downloadPath))) {
mkdir(dirname($downloadPath), 0777, true);
}
}
/**
* @param string $endpoint
*
* @return array<array<string, string>>
*/
private function getResponse(string $endpoint): array
{
$response = $this->circruitBreaker->call($endpoint, [], function () {
throw new \PrestaShopException('Unable to retrieve informations from Distribution API : cannot automatically update native modules for the moment.');
});
/** @var array<array<string, string>> $json */
$json = json_decode($response, true) ?: [];
return $json;
}
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
namespace PrestaShop\Module\DistributionApiClient\Middleware;
use Doctrine\Common\Cache\CacheProvider;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
class CachedHttpClient implements HttpClientInterface
{
private CacheProvider $cache;
private HttpClientInterface $client;
/**
* @param CacheProvider $cache
* @param array<string, mixed> $defaultOptions
* @param HttpClientInterface|null $client
*/
public function __construct(CacheProvider $cache, array $defaultOptions = [], ?HttpClientInterface $client = null)
{
$this->cache = $cache;
$this->client = $client ?? HttpClient::create($defaultOptions);
}
/**
* @param string $method
* @param string $url
* @param array<string, mixed> $options
*
* @return ResponseInterface
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$cacheKey = $this->getCacheKey($method, $url);
if ($this->cache->contains($cacheKey)) {
/** @var CachedResponse $cachedResponse */
$cachedResponse = $this->cache->fetch($cacheKey);
return $cachedResponse;
}
$response = $this->client->request($method, $url, $options);
if ($response->getStatusCode() !== 200) {
return $response;
}
$cachedResponse = new CachedResponse($response);
$this->cache->save($cacheKey, $cachedResponse);
return $cachedResponse;
}
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
/**
* @param array<string, mixed> $options
*
* @return static
*/
public function withOptions(array $options): static
{
// @phpstan-ignore-next-line
return new static($this->cache, $options);
}
private function getCacheKey(string $method, string $url): string
{
return md5($method . $url);
}
}

View File

@@ -0,0 +1,155 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
namespace PrestaShop\Module\DistributionApiClient\Middleware;
use Symfony\Component\HttpClient\Exception\JsonException;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Simple DTO containing the response data to allow serializing it into cache.
*/
class CachedResponse implements ResponseInterface
{
private int $statusCode;
/**
* @var string[][]
*/
private array $headers;
private string $content;
/**
* @var mixed[]|array|null
*/
private ?array $jsonData = null;
/**
* @var array<string, mixed>
*/
private array $info;
public function __construct(ResponseInterface $response)
{
$info = $response->getInfo();
if (is_array($info)) {
$this->info = [
'canceled' => $info['canceled'] ?? false,
'error' => $info['error'] ?? null,
'http_code' => $info['http_code'] ?? 0,
'http_method' => $info['http_method'] ?? 'GET',
'redirect_count' => $info['redirect_count'] ?? 0,
'redirect_url' => $info['redirect_url'] ?? null,
'start_time' => $info['start_time'] ?? 0.0,
'url' => $info['url'] ?? '',
'user_data' => $info['user_data'] ?? null,
];
} elseif (is_object($info)) {
$this->info = [
'canceled' => property_exists($info, 'canceled') ? $info->canceled : false,
'error' => property_exists($info, 'error') ? $info->error : null,
'http_code' => property_exists($info, 'http_code') ? $info->http_code : 0,
'http_method' => property_exists($info, 'http_method') ? $info->http_method : 'GET',
'redirect_count' => property_exists($info, 'redirect_count') ? $info->redirect_count : 0,
'redirect_url' => property_exists($info, 'redirect_url') ? $info->redirect_url : null,
'start_time' => property_exists($info, 'start_time') ? $info->start_time : 0.0,
'url' => property_exists($info, 'url') ? $info->url : '',
'user_data' => property_exists($info, 'user_data') ? $info->user_data : null,
];
} else {
$this->info = [
'canceled' => false,
'error' => null,
'http_code' => 0,
'http_method' => 'GET',
'redirect_count' => 0,
'redirect_url' => null,
'start_time' => 0.0,
'url' => '',
'user_data' => null,
];
}
$this->statusCode = $response->getStatusCode();
$this->headers = $response->getHeaders(false);
$this->content = $response->getContent(false);
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getHeaders(bool $throw = true): array
{
return $this->headers;
}
public function getContent(bool $throw = true): string
{
return $this->content;
}
/**
* @param bool $throw
*
* @return array|mixed[]
*/
public function toArray(bool $throw = true): array
{
// Code copied from CommonResponseTrait
if ('' === $content = $this->getContent($throw)) {
throw new JsonException('Response body is empty.');
}
if (null !== $this->jsonData) {
return $this->jsonData;
}
try {
$content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
/** @var string $url */
$url = $this->getInfo('url');
throw new JsonException($e->getMessage() . sprintf(' for "%s".', $url), $e->getCode());
}
if (!\is_array($content)) {
/** @var string $url */
$url = $this->getInfo('url');
throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned for "%s".', get_debug_type($content), $url));
}
return $this->jsonData = $content;
}
public function cancel(): void
{
}
public function getInfo(?string $type = null): mixed
{
if (null !== $type) {
return $this->info[$type] ?? null;
}
return $this->info;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
declare(strict_types=1);
namespace PrestaShop\Module\DistributionApiClient;
use Context;
use Link;
use RuntimeException;
/**
* Provides information about the shop, to be added to API calls
*/
class ShopDataProvider
{
/**
* Returns the default URL to shop's Front office
*
* @return string
*/
public function getShopUrl(): string
{
$context = Context::getContext();
if (!$context instanceof Context || !$context->link instanceof Link) {
throw new RuntimeException('Unable to retrieve the contextual Link instance');
}
return $context->link->getBaseLink();
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* 2007-2016 PrestaShop
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License (AFL 3.0)
* that is bundled with this package in the file LICENSE.txt.
* It is also available through the world-wide-web at this URL:
* http://opensource.org/licenses/afl-3.0.php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to http://www.prestashop.com for more information.
*
* @author PrestaShop SA <contact@prestashop.com>
* @copyright 2007-2016 PrestaShop SA
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
* International Registered Trademark & Property of PrestaShop SA
*/
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_1_2_0($module): bool
{
return $module->registerTab();
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* 2007-2016 PrestaShop
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License (AFL 3.0)
* that is bundled with this package in the file LICENSE.txt.
* It is also available through the world-wide-web at this URL:
* http://opensource.org/licenses/afl-3.0.php
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to http://www.prestashop.com for more information.
*
* @author PrestaShop SA <contact@prestashop.com>
* @copyright 2007-2016 PrestaShop SA
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
* International Registered Trademark & Property of PrestaShop SA
*/
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_2_1_0($module): bool
{
return $module->registerTab();
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/modules/ps_distributionapiclient/views/js/vue/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Community: Wall of Fame</title>
<script type="module" crossorigin src="/modules/ps_distributionapiclient/views/js/vue/assets/index.js"></script>
</head>
<body>
<div id="wall-of-fame-vue-app"></div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
{#**
* 2007-2020 PrestaShop and Contributors
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License 3.0 (AFL-3.0)
* that is bundled with this package in the file LICENSE.txt.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA <contact@prestashop.com>
* @copyright 2007-2020 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0)
* International Registered Trademark & Property of PrestaShop SA
*#}
{% extends '@PrestaShop/Admin/layout.html.twig' %}
{% block stylesheets %}
{{ parent() }}
{% endblock %}
{% block content %}
<div id="wall-of-fame-vue-app"></div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="module" src="{{ asset('../modules/ps_distributionapiclient/views/js/vue/assets/index.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

View File

@@ -0,0 +1,22 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
PuikAvatar: typeof import('@prestashopcorp/puik-components')['PuikAvatar']
PuikButton: typeof import('@prestashopcorp/puik-components')['PuikButton']
PuikCard: typeof import('@prestashopcorp/puik-components')['PuikCard']
PuikIcon: typeof import('@prestashopcorp/puik-components')['PuikIcon']
PuikLink: typeof import('@prestashopcorp/puik-components')['PuikLink']
PuikModal: typeof import('@prestashopcorp/puik-components')['PuikModal']
PuikTable: typeof import('@prestashopcorp/puik-components')['PuikTable']
PuikTag: typeof import('@prestashopcorp/puik-components')['PuikTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}

View File

@@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Community: Wall of Fame</title>
</head>
<body>
<div id="wall-of-fame-vue-app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1 @@
@import "@prestashopcorp/puik-theme/index.css";

View File

@@ -0,0 +1,2 @@
@import './base.css';

View File

@@ -0,0 +1,11 @@
import "@/assets/main.css";
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(router);
app.mount("#wall-of-fame-vue-app");

View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue';
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
}
],
})
export default router

View File

@@ -0,0 +1,37 @@
export interface Company {
name: string
rank: number
merged_pull_requests: number
pull_requests_percent: number
avatar_url: string
html_url: string
}
export interface Contributor {
login: string
id: number
avatar_url: string
html_url: string
name: string
company: string | null
blog: string | null
location: string | null
bio: string | null
email_domain: string | null
contributions: number
repositories: Record<string, number>
categories: Record<string, {
total: number
repositories: Record<string, number>
}>
}
export interface NewContributor {
login: string
name: string
avatar_url: string
html_url: string
contributions: number
firstContributionAt: string
}

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import 'vue3-carousel/carousel.css'
import { ref, onMounted } from 'vue'
import HeaderSectionView from '@/views/sections/HeaderSectionView.vue'
import TopSectionView from '@/views/sections/TopSectionView.vue'
import NewContributorsSectionView from '@/views/sections/NewContributorsSectionView.vue'
import ContributeSectionView from '@/views/sections/ContributeSectionView.vue'
import type { Company, Contributor, NewContributor } from '@/types'
const totalMergedPr = ref<number>(0)
const prestaMergedPrbyPercent = ref<number>(0)
const topCompanies = ref<Company[]>([])
const contributorsData = ref<Contributor[]>([])
const topContributors = ref<Contributor[]>([])
const newContributors = ref<NewContributor[]>([])
onMounted(async () => {
try {
const response = await fetch('https://contributors.prestashop-project.org/newcontributors.json')
if (!response.ok) throw new Error('Error loading new contributors')
const data: Record<string, NewContributor> = await response.json()
newContributors.value = Object.values(data)
} catch (error) {
console.error('Error loading new contributors:', error)
}
try {
const response = await fetch('https://contributors.prestashop-project.org/topcompanies.json')
if (!response.ok) throw new Error('Error loading top companies')
const data = await response.json()
topCompanies.value = data.companies.slice(0, 5)
const total: number =
data.companies.reduce((acc: number, company: Company) => acc + company.merged_pull_requests, 0)
+ data.community.merged_pull_requests
totalMergedPr.value = total ?? 0
const prestashopCompany = data.companies.find((company: Company) => company.name === 'PrestaShop')
prestaMergedPrbyPercent.value = prestashopCompany.pull_requests_percent ?? 0
} catch (error) {
console.error('Error loading top companies:', error)
}
try {
const response = await fetch('https://contributors.prestashop-project.org/contributors_prs.json')
if (!response.ok) throw new Error('Error loading contributors data')
const data = await response.json()
// Filter out non-contributor entries and nulls (e.g., "updatedAt") from the JSON object
const contributorsOnly = Object.values(data).filter(
(item): item is Contributor =>
item !== null && typeof item === 'object' && 'contributions' in item,
)
contributorsData.value = contributorsOnly
topContributors.value = contributorsOnly.slice(0, 5)
} catch (error) {
console.error('Error loading contributors data:', error)
}
})
</script>
<template>
<div class="wof-container">
<HeaderSectionView
:total-merged-pr="totalMergedPr"
:presta-merged-pr-by-percent="prestaMergedPrbyPercent"
/>
<main>
<TopSectionView :top-contributors="topContributors" :top-companies="topCompanies" />
<NewContributorsSectionView :new-contributors="newContributors" />
<ContributeSectionView
contributeLink="https://devdocs.prestashop-project.org/9/contribute/contribute-pull-requests/"
slackLink="https://www.prestashop-project.org/slack/"
/>
</main>
</div>
</template>
<style>
:root {
--wof-section-gap: 1.5rem;
--wof-section-padding: 2.5rem 1rem;
--wof-section-padding-lg: 4rem;
--wof-avatar-bg: #fff;
--wof-jumbotron-size-sm: 2.5rem;
--wof-h1-size-sm: 1.75rem;
}
.wof-section {
padding: var(--wof-section-padding);
display: flex;
flex-direction: column;
gap: var(--wof-section-gap);
}
@media (min-width: 768px) {
.wof-section {
padding: var(--wof-section-padding-lg);
}
}
.puik-tag p {
margin-bottom: 0;
}
a.puik-button:hover {
text-decoration: none;
}
.puik-avatar.puik-avatar--photo {
background-color: var(--wof-avatar-bg);
}
@media (max-width: 768px) {
.puik-brand-jumbotron {
font-size: var(--wof-jumbotron-size-sm);
}
.puik-h1 {
font-size: var(--wof-h1-size-sm);
}
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { type PuikTableHeader } from '@prestashopcorp/puik-components'
defineProps<{
title: string
description?: string
externalLinkHref?: string
externalLinkContent?: string
headers: PuikTableHeader[]
items: any[]
stickyLastCol?: boolean
fullWidth?: boolean
}>()
const emit = defineEmits<{
(e: 'view', item: any): void
(e: 'action', payload: any): void
}>()
</script>
<template>
<puik-card class="wof-top-card">
<div class="wof-top-card__title-container">
<h3 class="wof-top-card__title puik-h2">{{ title }}</h3>
<puik-button
v-if="externalLinkContent && externalLinkHref"
variant="secondary"
:aria-label="externalLinkContent"
class="wof-top-card__external-link"
>
<puik-link
:href="externalLinkHref"
target="_blank"
:aria-label="externalLinkContent"
>
{{ externalLinkContent }}
</puik-link>
</puik-button>
</div>
<p v-if="description" class="wof-top-card__description puik-body-default">{{ description }}</p>
<puik-table
v-if="items?.length"
:headers="headers"
:items="items"
:stickyLastCol="stickyLastCol"
:fullWidth="fullWidth"
>
<template
v-for="header in headers"
:key="header.value"
v-slot:[`item-${header.value}`]="slotProps"
>
<slot
:name="`item-${header.value}`"
v-bind="slotProps"
>
<span class="puik-body-default">{{ slotProps.item[header.value] }}</span>
</slot>
</template>
</puik-table>
</puik-card>
</template>
<style>
:root {
--wof-top-card-title-size-sm: 1.25rem;
}
.wof-top-card {
display: flex;
flex-direction: column;
flex-grow: 1;
max-width: 100%;
gap: 0 !important;
}
.wof-top-card__title-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.wof-top-card__title,
.wof-top-card__description,
.wof-top-card__external-link {
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.wof-top-card__title {
font-size: var(--wof-top-card-title-size-sm);
}
}
</style>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import type { Contributor } from '@/types'
const props = defineProps<{
contributor: Contributor
isOpen: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const close = () => emit('close')
</script>
<template>
<puik-modal
class="wof-top-modal"
size="large"
variant="feedback"
:is-open="isOpen"
@close="close"
>
<puik-button class="wof-top-modal__close-btn" variant="text" size="sm" @click="close">
<puik-icon icon="close" font-size="1.25rem"/>
</puik-button>
<div class="wof-top-modal__container">
<div class="wof-top-modal__side-content">
<div class="wof-top-modal__avatar">
<img :src="contributor.avatar_url" alt="contributor avatar" />
</div>
<div class="wof-top-modal__title">
<h3 class="puik-h3">{{ contributor.name }}</h3>
<puik-tag v-if="contributor.company" :content="contributor.company" variant="blue" />
</div>
<div v-if="contributor.location" class="wof-top-modal__side-content__item">
<puik-icon icon="location_on" :fill="0" />
<div class="wof-top-modal__side-content__item-infos">
<span class="wof-top-modal__side-content__item-title puik-body-default">Location</span>
<span class="wof-top-modal__side-content__item-value puik-body-default">
{{ contributor.location }}
</span>
</div>
</div>
<div v-if="contributor.company" class="wof-top-modal__side-content__item">
<puik-icon icon="work" :fill="0" />
<div class="wof-top-modal__side-content__item-infos">
<span class="wof-top-modal__side-content__item-title puik-body-default">
Current role(s)
</span>
<span class="wof-top-modal__side-content__item-value puik-body-default">
{{ contributor.company}}
</span>
</div>
</div>
<div v-if="contributor.html_url" class="wof-top-modal__side-content__item">
<puik-icon icon="location_on" :fill="0" />
<div class="wof-top-modal__side-content__item-infos">
<span class="wof-top-modal__side-content__item-title puik-body-default">GitHub</span>
<puik-link
:href="contributor.html_url"
target="_blank"
aria-label="contributor github"
class="wof-top-modal__side-content__item-value puik-body-default"
>
{{ contributor.html_url }}
</puik-link>
</div>
</div>
<div v-if="contributor.blog" class="wof-top-modal__side-content__item">
<puik-icon icon="desktop_mac" :fill="0" />
<div class="wof-top-modal__side-content__item-infos">
<span class="wof-top-modal__side-content__item-title puik-body-default">Website</span>
<puik-link
:href="contributor.blog"
target="_blank"
aria-label="contributor blog"
class="wof-top-modal__side-content__item-value puik-body-default"
>
{{ contributor.blog }}
</puik-link>
</div>
</div>
</div>
<div class="wof-top-modal__main-content">
<p class="puik-body-default-medium">{{ contributor.contributions }} contributions</p>
<div class="wof-top-modal__categories">
<puik-card
class="wof-top-modal__categories__card"
v-for="(data, category) in contributor.categories"
:key="category"
>
<p class="puik-h2">{{ data.total }}</p>
<p class="puik-body-default">{{ category }}</p>
</puik-card>
</div>
</div>
</div>
</puik-modal>
</template>
<style>
:root {
--wof-color-bg-modal: #ffffff;
--wof-color-bg-modal-side-panel: #dddddd;
--wof-color-side-panel-item-value: #5e5e5e;
--wof-padding-top-modal: 8.5rem;
}
.wof-top-modal__close-btn {
position: absolute;
right: 1rem;
top: 1rem;
}
.wof-top-modal .puik-modal__dialogPanelContainer__dialogPanel {
background-color: var(--wof-color-bg-modal);
padding: 0;
}
.wof-top-modal .puik-modal__dialogPanelContainer {
padding-top: var(--wof-padding-top-modal);
}
.wof-top-modal__container {
display: flex;
flex-direction: column;
flex-grow: 1;
background-color: var(--wof-color-bg-modal-side-panel);
overflow: auto;
@media screen and (min-width: 768px) {
flex-direction: row;
}
}
.wof-top-modal__title h3 {
margin-bottom: 0;
}
.wof-top-modal__side-content {
padding: 20px;
min-width: min-content;
min-height: fit-content;
display: flex;
flex-direction: column;
align-items: self-start;
gap: 1rem;
background-color: var(--wof-color-bg-modal);
overflow-y: auto;
@media screen and (min-width: 768px) {
padding: 40px;
}
}
.wof-top-modal__avatar {
border-radius: 50%;
min-height: 128px;
overflow: hidden;
}
.wof-top-modal__avatar img {
width: 128px;
height: 128px;
object-fit: cover;
object-position: center;
border-radius: 50%;
}
.wof-top-modal__side-content__item {
display: flex;
align-items: start;
gap: 0.5rem;
}
.wof-top-modal__side-content__item-infos {
display: flex;
flex-direction: column;
}
.wof-top-modal__side-content__item-title {
line-height: 1;
}
.wof-top-modal__side-content__item-value {
color: var(--wof-color-side-panel-item-value);
}
.wof-top-modal__main-content {
padding: 20px;
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: fit-content;
gap: 1rem;
overflow: auto;
@media screen and (min-width: 768px) {
padding: 40px;
}
}
.wof-top-modal__categories {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.wof-top-modal__categories__card {
padding: 1rem;
max-height: fit-content;
display: flex;
flex-direction: column;
gap: 0;
}
.wof-top-modal__categories__card p {
margin: 0;
}
</style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
defineProps<{
contributeLink: string
slackLink: string
}>()
</script>
<template>
<section class="wof-section wof-contribute-section">
<div class="wof-contribute-section__content">
<h2 class="wof-contribute-section__tite puik-h2"> How to contribute?</h2>
<p class="wof-contribute-section__description puik-body-default">
Join the open-source movement by contributing to PrestaShop on GitHubwhether its code,
documentation, or ideas. Every contribution counts!
</p>
</div>
<div class="wof-contribute-section__links">
<a
:href="contributeLink"
target="_blank"
aria-label="Contribute to PrestaShop"
rel="noopener noreferrer"
>
<puik-button variant="primary">
Contribute
</puik-button>
</a>
<a
:href="slackLink"
target="_blank"
aria-label="join PrestaShop Slack Open Source"
rel="noopener noreferrer"
>
<puik-button variant="secondary">
Join Slack
</puik-button>
</a>
</div>
</section>
</template>
<style>
:root {
--wof-contribute-section-bg: #bde9c9;
--wof-contribute-section-link-hover-color: #ffffff;
--wof-description-max-width: 1200px;
}
.wof-contribute-section {
justify-content: center;
align-items: center;
background-color: var(--wof-contribute-section-bg);
}
.wof-contribute-section__content {
display: flex;
flex-direction: column;
align-items: center;
}
.wof-contribute-section__tite {
margin-bottom: 1rem;
}
.wof-contribute-section__description {
margin-bottom: 0;
max-width: var(--wof-description-max-width);
text-align: center;
}
.wof-contribute-section__links {
display: flex;
gap: 1rem;
}
.wof-contribute-section__links a.puik-button {
text-decoration: none !important;
}
.wof-contribute-section__links a.puik-button--primary:hover {
color: var(--wof-contribute-section-link-hover-color) !important;
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
defineProps<{
totalMergedPr: number
prestaMergedPrByPercent: number
}>()
</script>
<template>
<header class="wof-section wof-header-section">
<h1 class="wof-header-section__title puik-brand-jumbotron">MEET our community Heroes</h1>
<p class="wof-header-section__description puik-body-default">
From day one, PrestaShop has thrived as an open-source platform powered by a talented
community of developers, merchants, and contributors. We all work together to improve and
support the scalability of the PrestaShop e-commerce platform. By remaining the main
contributors to its development, PrestaShop ensures long-term sustainability for everyone in
the ecosystem. The project grows with each contribution, and with each contribution our
contributors expertise grows. Take a look at our community.
</p>
<div class="wof-header-section__kpis-container">
<div class="wof-header-section__kpis-item">
<span class="wof-header-section__kpis-value puik-brand-h1">{{ totalMergedPr }}</span>
<span class="wof-header-section__kpis-label puik-body-default">Total Contributions</span>
</div>
<div class="wof-header-section__kpis-item">
<span class="wof-header-section__kpis-value puik-brand-h1">
{{ prestaMergedPrByPercent }}%
</span>
<span class="wof-header-section__kpis-label puik-body-default">Contributions by PrestaShop</span>
</div>
<div class="wof-header-section__kpis-item">
<span class="wof-header-section__kpis-value puik-brand-h1">
{{ (100 - prestaMergedPrByPercent).toFixed(2) }}%
</span>
<span class="wof-header-section__kpis-label puik-body-default">Contributions by Community</span>
</div>
</div>
</header>
</template>
<style>
:root {
--wof-header-section-bg: #1d1d1b;
--wof-header-section-text: #ffffff;
--wof-description-max-width: 1200px;
}
.wof-header-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
background-color: var(--wof-header-section-bg);
}
.wof-header-section * {
color: var(--wof-header-section-text);
}
.wof-header-section__title {
margin-bottom: 0;
text-align: center;
text-transform: uppercase;
}
.wof-header-section__description {
max-width: var(--wof-description-max-width);
text-align: center;
}
.wof-header-section__kpis-container {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 2rem;
}
.wof-header-section__kpis-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 190px;
}
</style>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import 'vue3-carousel/carousel.css'
import { Carousel, Slide, Navigation } from 'vue3-carousel'
import type { NewContributor } from '@/types'
defineProps<{
newContributors: NewContributor[]
}>()
const carousel_config = {
itemsToShow: 1,
gap: 16,
snapAlign: 'center' as const,
breakpointMode: 'carousel' as const,
breakpoints: {
0: {
itemsToShow: 1,
snapAlign: 'center' as const,
},
476: {
itemsToShow: 2,
snapAlign: 'start' as const,
},
992: {
itemsToShow: 3,
snapAlign: 'start' as const,
},
1024: {
itemsToShow: 4,
snapAlign: 'start' as const,
},
1200: {
itemsToShow: 5,
snapAlign: 'start' as const,
},
1600: {
itemsToShow: 6,
snapAlign: 'start' as const,
},
},
}
</script>
<template>
<section class="wof-section wof-new-contributors-section">
<div>
<h2 class="wof-new-contributors-section__title puik-h2">👋 Say hello to our new contributors</h2>
<p class="wof-new-contributors-section__description puik-body-default">
Fresh commits, fresh faces. Meet the contributors who just joined!
</p>
</div>
<Carousel v-bind="carousel_config">
<Slide v-for="(newContributor, index) in newContributors" :key="index">
<puik-card class="wof-new-contributors-section__card">
<img
class="wof-new-contributors-section__img"
:src="newContributor.avatar_url"
:alt="`${newContributor.name ?? newContributor.login} avatar`"
/>
<h3 class="puik-h3">{{ newContributor.name ?? newContributor.login}}</h3>
<p class="puik-body-default">{{ newContributor.login }}</p>
<p class="puik-body-small">{{ newContributor.contributions }} contribution{{ newContributor.contributions > 1 ? "s" : "" }}</p>
</puik-card>
</Slide>
<template #addons>
<div class="wof-carousel__nav-container">
<Navigation>
<template #prev>
<puik-icon icon="keyboard_arrow_left" />
</template>
<template #next>
<puik-icon icon="keyboard_arrow_right" />
</template>
</Navigation>
</div>
</template>
</Carousel>
</section>
</template>
<style>
:root {
--wof-new-contributors-section-bg: #a4dbe8;
--wof-carousel-nav-bg: #fff;
--wof-carousel-nav-border: #1d1d1b;
--wof-carousel-nav-disabled-bg: #f7f7f7;
--wof-carousel-nav-disabled-border: #ddd;
--wof-carousel-nav-hover: #000;
}
.wof-new-contributors-section {
background-color: var(--wof-new-contributors-section-bg);
}
.wof-new-contributors-section__card {
flex-grow: 1;
gap: 0;
}
.wof-new-contributors-section__card * {
margin-bottom: 0;
}
.wof-new-contributors-section__title {
margin-bottom: 1rem;
}
.wof-new-contributors-section__description {
margin-bottom: 0;
padding-right: 96px;
}
.wof-new-contributors-section__img {
width: 100%;
object-fit: cover;
object-position: center;
}
.carousel {
--vc-nav-border-radius: 50%;
--vc-nav-width: 36px;
--vc-nav-height: 36px;
}
.wof-carousel__nav-container {
margin: 1rem 0 1.5rem 1rem;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: 0;
bottom: 100%;
gap: 0.5rem;
}
.wof-carousel__nav-container .carousel__next,
.wof-carousel__nav-container .carousel__prev {
background: var(--vc-nav-background, var(--wof-carousel-nav-bg));
background-color: var(--wof-carousel-nav-bg);
border: 1px solid var(--wof-carousel-nav-border);
border-radius: var(--vc-nav-border-radius);
color: var(--vc-nav-color);
font-size: var(--vc-nav-height);
height: var(--vc-nav-height);
position: relative;
transform: translateY(0);
width: var(--vc-nav-width);
}
.wof-carousel__nav-container .carousel__next--disabled,
.wof-carousel__nav-container .carousel__prev--disabled {
background-color: var(--wof-carousel-nav-disabled-bg);
border-color: var(--wof-carousel-nav-disabled-border);
opacity: 1;
}
.wof-carousel__nav-container .carousel__next--disabled .puik-icon,
.wof-carousel__nav-container .carousel__prev--disabled .puik-icon {
opacity: 0.3;
}
.wof-carousel__nav-container .carousel__next:hover,
.wof-carousel__nav-container .carousel__prev:hover {
color: var(--wof-carousel-nav-hover);
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import TopContributorsView from '@/views/sections/sub-sections/TopContributorsView.vue';
import TopCompaniesView from '@/views/sections/sub-sections/TopCompaniesView.vue';
import type { Contributor, Company } from '@/types';
defineProps<{
topContributors: Contributor[]
topCompanies: Company[]
}>()
</script>
<template>
<section class="wof-section wof-top-section">
<h2 class="wof-top-section__title puik-h1">PrestaShop Projects top contributors</h2>
<div class="wof-top-section__cards">
<TopCompaniesView :top-companies="topCompanies" />
<TopContributorsView :top-contributors="topContributors" />
</div>
</section>
</template>
<style>
:root {
--wof-top-section-padding: 2.5rem 1rem;
--wof-top-section-padding-lg: 4rem;
--wof-top-section-rank-first: #ffd999;
--wof-top-section-rank-second: #eeeeee;
--wof-top-section-rank-third: #e7bd94;
}
.wof-section.wof-top-section {
padding: var(--wof-top-section-padding);
}
@media (min-width: 768px) {
.wof-section.wof-top-section {
padding: var(--wof-top-section-padding-lg);
}
}
.wof-top-section__title {
margin-bottom: 0;
}
.wof-top-section__cards {
display: flex;
flex-wrap: wrap;
gap: 2rem;
}
.wof-top-section__rank {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.wof-top-section__rank span {
line-height: 0;
}
.wof-top-section__rank--first {
background-color: var(--wof-top-section-rank-first);
}
.wof-top-section__rank--second {
background-color: var(--wof-top-section-rank-second);
}
.wof-top-section__rank--third {
background-color: var(--wof-top-section-rank-third);
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<section class="wof-section wof-wall-of-fame-section">
<div>
<h2 class="puik-h2">🏆 PrestaShop Projects Wall of fame</h2>
<p class="puik-body-default">
The PrestaShop Wall of Fame: built by the best, committed to the core.
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref } from 'vue'
import TopCard from '@/views/components/TopCard.vue'
import type { PuikTableHeader } from '@prestashopcorp/puik-components'
import type { Company } from '@/types'
defineProps<{
topCompanies: Company[]
}>()
const headers: PuikTableHeader[] = [
{
text: 'Rank',
value: 'rank',
size: 'sm',
align: 'center',
searchable: false,
},
{
text: 'Logo',
value: 'logo',
size: 'sm',
align: 'center',
searchable: false,
},
{
text: 'Name',
value: 'name',
size: 'md',
align: 'left',
searchable: true,
},
{
text: 'Contributions',
value: 'merged_pull_requests',
size: 'sm',
align: 'center',
searchable: false,
},
{
value: 'actions',
size: 'sm',
align: 'center',
preventExpand: true,
searchSubmit: true,
},
]
const stickyLastCol = ref(false)
const fullWidth = ref(true)
</script>
<template>
<TopCard
title="🚀 Top companies"
description="Meet the top companies who are helping us strengthen PrestaShop."
:headers="headers"
:items="topCompanies"
:stickyLastCol="stickyLastCol"
:full-width="fullWidth"
>
<template #item-rank="{ index }">
<div
:class="[
'wof-top-section__rank',
{ 'wof-top-section__rank--first': index === 0 },
{ 'wof-top-section__rank--second': index === 1 },
{ 'wof-top-section__rank--third': index === 2 }
]"
>
<span class="puik-body-default-bold">{{ index + 1 }}</span>
</div>
</template>
<template #item-logo="{ item }">
<puik-avatar v-if="item.avatar_url" size="large" type="photo" :src="item.avatar_url" />
<puik-avatar v-else size="large" :first-name="item.name" :single-initial="false" />
</template>
<template #item-name="{ item }">
<div class="wof-top-contributors__name">
<span class="puik-body-default">{{ item.name }}</span>
</div>
</template>
<template #item-actions="{ item }">
<a
:href="item.html_url"
target="_blank"
aria-label="view profile"
rel="noopener noreferrer"
>
<puik-button
variant="text"
right-icon="visibility"
aria-label="view profile icon"
/>
</a>
</template>
</TopCard>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref } from 'vue'
import TopCard from '@/views/components/TopCard.vue'
import TopModal from '@/views/components/TopModal.vue'
import type { PuikTableHeader } from '@prestashopcorp/puik-components'
import type { Contributor } from '@/types'
defineProps<{
topContributors: Contributor[]
}>()
const headers: PuikTableHeader[] = [
{
text: 'Rank',
value: 'rank',
size: 'sm',
align: 'center',
searchable: false,
},
{
text: 'Avatar',
value: 'avatar',
size: 'sm',
align: 'center',
searchable: false,
},
{
text: 'Name',
value: 'name',
size: 'md',
align: 'left',
searchable: true,
},
{
text: 'Contributions',
value: 'mergedPullRequests',
size: 'sm',
align: 'center',
searchable: false,
},
{
value: 'actions',
size: 'sm',
align: 'center',
preventExpand: true,
searchSubmit: true,
},
]
const stickyLastCol = ref(false)
const fullWidth = ref(true)
const modalContributorItem = ref()
const isModalOpen = ref(false)
const openModal = (contributor: any) => {
modalContributorItem.value = contributor
isModalOpen.value = true
}
const closeModal = () => {
isModalOpen.value = false
}
</script>
<template>
<TopCard
title="🔥 Top contributors"
description="These experts spent hours improving PrestaShop's quality."
external-link-content="View all"
external-link-href="https://contributors.prestashop-project.org/"
:headers="headers"
:items="topContributors"
:stickyLastCol="stickyLastCol"
:full-width="fullWidth"
@view="openModal"
>
<template #item-rank="{ index }">
<div
:class="[
'wof-top-section__rank',
{ 'wof-top-section__rank--first': index === 0 },
{ 'wof-top-section__rank--second': index === 1 },
{ 'wof-top-section__rank--third': index === 2 }
]"
>
<span class="puik-body-default-bold">{{ index + 1 }}</span>
</div>
</template>
<template #item-avatar="{ item }">
<puik-avatar size="large" type="photo" :src="item.avatar_url" />
</template>
<template #item-name="{ item }">
<div class="wof-top-contributors__name">
<span v-if="item.name" class="puik-body-default">{{ item.name }}</span>
<puik-tag v-if="item.company" :content="item.company" variant="blue" />
</div>
</template>
<template #item-actions="{ item }">
<puik-button
@click="openModal(item)"
variant="text"
right-icon="visibility"
aria-label="view profile"
/>
</template>
</TopCard>
<TopModal
v-if="modalContributorItem"
:contributor="modalContributorItem"
:isOpen="isModalOpen"
@close="closeModal"
/>
</template>

View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@@ -0,0 +1,44 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueDevTools from 'vite-plugin-vue-devtools';
import Components from 'unplugin-vue-components/vite';
import AutoImport from 'unplugin-auto-import/vite';
import { PuikResolver } from '@prestashopcorp/puik-resolver';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
Components({
resolvers: [PuikResolver()],
}),
AutoImport({
resolvers: [PuikResolver()],
}),
cssInjectedByJsPlugin(),
],
server: {
origin: "http://localhost:5173",
},
base: '/modules/ps_distributionapiclient/views/js/vue/',
build: {
cssCodeSplit: false,
outDir: "../views/js/vue",
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: () => "index.js",
entryFileNames: `assets/[name].js`,
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})