feat: WIP

This commit is contained in:
Nikita Kiselev
2025-07-10 18:31:02 +03:00
parent c3664025ba
commit 846fa64fb4
68 changed files with 4144 additions and 118 deletions

61
.github/workflows/main.yaml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Telegram Mini App Shop Builder
on:
push:
branches:
- master
permissions:
contents: write
jobs:
module-build:
if: github.ref == 'refs/heads/master'
name: Build module.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build module
run: |
bash scripts/ci/build.sh "${GITHUB_WORKSPACE}"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: oc_telegram_shop.ocmod.zip
path: ./build/oc_telegram_shop.ocmod.zip
retention-days: 1
release:
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
needs: [module-build]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # to fetch tags
- name: Extract tag and set filename
id: meta
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "filename=oc_telegram_shop_${TAG}.ocmod.zip" >> $GITHUB_OUTPUT
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: oc_telegram_shop.ocmod.zip
path: ./build
- name: Rename artifact file
run: mv ./build/oc_telegram_shop.ocmod.zip ./build/${{ steps.meta.outputs.filename }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
files: ./build/${{ steps.meta.outputs.filename }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -24,4 +24,6 @@ dist-ssr
*.sw? *.sw?
src/* src/*
spa/node_modules spa/node_modules
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
module/oc_telegram_shop/upload/image

View File

@@ -22,15 +22,11 @@ start:
docker compose up -d docker compose up -d
ssh: ssh:
docker compose exec web bash docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash
link: link:
docker compose exec web bash -c "php ./scripts/link.php" docker compose exec web bash -c "php ./scripts/link.php"
dev: dev:
$(MAKE) link && \ $(MAKE) link && \
cd frontend && npm run dev cd spa && npm run dev
build:
docker build -t oc_layout_pro_build_prod -f docker/build.dockerfile . && \
docker run --rm -v "./build":/build oc_layout_pro_build_prod

View File

@@ -1,6 +1,6 @@
services: services:
web: web:
image: webdevops/php-apache-dev:${PHP_VERSION} image: webdevops/php-apache-dev:7.4
platform: linux/amd64 platform: linux/amd64
volumes: volumes:
- "./src:/web" - "./src:/web"

View File

@@ -0,0 +1,72 @@
<?php
use App\ApplicationFactory;
use Cart\Currency;
use Cart\Tax;
$sysLibPath = rtrim(DIR_SYSTEM, '/') . '/library/oc_telegram_shop';
$basePath = rtrim(DIR_APPLICATION, '/') . '/..';
if (is_readable($sysLibPath . '/oc_telegram_shop.phar')) {
require_once "phar://{$sysLibPath}/oc_telegram_shop.phar/vendor/autoload.php";
} elseif (is_dir("$basePath/oc_telegram_shop")) {
require_once "$basePath/oc_telegram_shop/vendor/autoload.php";
} else {
throw new RuntimeException('Unable to locate bulk products directory.');
}
class Controllertgshophandle extends Controller
{
public function index()
{
$app = ApplicationFactory::create([
'oc_config_tax' => $this->config->get('config_tax'),
'oc_currency' => $this->session->data['currency'],
'timezone' => $this->config->get('config_timezone', 'UTC'),
'lang' => $this->config->get('config_admin_language'),
'language_id' => (int)$this->config->get('config_language_id'),
'shop_base_url' => HTTPS_SERVER,
'dir_image' => DIR_IMAGE,
'db' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
],
'logs' => [
'path' => DIR_LOGS,
],
]);
$app->bind(Url::class, function () {
return $this->url;
});
$app->bind(Currency::class, function () { return $this->currency; });
$app->bind(Tax::class, function () { return $this->tax; });
$this->load->model('tool/image');
$app->bind('image_resize', function () {
return function ($path, $width, $height) {
if (is_file(DIR_IMAGE . $path)) {
return $this->model_tool_image->resize($path, $width, $height);
}
return $this->model_tool_image->resize('no_image.png', $width, $height);
};
});
$this->load->model('checkout/order');
$app->bind('model_checkout_order', function () {
return $this->model_checkout_order;
});
$app->bootAndHandleRequest();
}
public function spa()
{
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "nikitakiselev/oc_telegram_shop",
"autoload": {
"psr-4": {
"Openguru\\OpenCartFramework\\": "framework/",
"App\\": "src/"
},
"files": [
"framework/Support/helpers.php"
]
},
"authors": [
{
"name": "Nikita Kiselev",
"email": "mail@nikitakiselev.ru"
}
],
"require": {
"php": "^7.4",
"ext-pdo": "*",
"psr/container": "^2.0",
"ext-json": "*"
},
"require-dev": {
"roave/security-advisories": "dev-latest"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
<?php
namespace Openguru\OpenCartFramework;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Logger\Logger;
use Openguru\OpenCartFramework\Router\Router;
use Openguru\OpenCartFramework\Support\ExecutionTimeProfiler;
class Application extends Container
{
private static Application $instance;
private static array $events = [];
private array $serviceProviders = [];
/**
* @var ExecutionTimeProfiler
*/
private ExecutionTimeProfiler $profiler;
public static function getInstance(): Application
{
return static::$instance;
}
private function bootKernelServices(): void
{
$this->singleton(ExecutionTimeProfiler::class, function () {
return new ExecutionTimeProfiler();
});
$this->profiler = $this->get(ExecutionTimeProfiler::class);
$this->profiler->start();
$this->singleton(Container::class, function (Container $container) {
return $container;
});
$this->singleton(Logger::class, function (Container $container) {
$path = $container->getConfigValue('logs.path') . '/oc_telegram_shop.log';
return new Logger($path, Logger::LEVEL_INFO);
});
$this->singleton(Settings::class, function (Container $container) {
return new Settings($container->getConfigValue());
});
$errorHandler = new ErrorHandler(
$this->get(Logger::class)
);
$errorHandler->register();
}
public function boot(): Application
{
$this->bootKernelServices();
$this->initializeEventDispatcher(static::$events);
DependencyRegistration::register($this, $this->serviceProviders);
static::$instance = $this;
$this->profiler->addCheckpoint('Bootstrap Application');
return $this;
}
public function handleRequest(Request $request): JsonResponse
{
$this->bind(Request::class, function () use ($request) {
return $request;
});
$router = $this->get(Router::class);
$apiAction = $request->get('api_action');
$action = $router->resolve($apiAction);
[$controller, $method] = $action;
if (!class_exists($controller) || !method_exists($controller, $method)) {
throw new InvalidArgumentException('Invalid action: ' . $controller . '->' . $method);
}
$this->profiler->addCheckpoint('Handle Router.');
$response = $this->call($controller, $method);
$this->profiler->addCheckpoint('Handle HTTP request.');
return $response;
}
public function bootAndHandleRequest(): void
{
$this->boot();
$request = Request::createFromGlobals();
$response = $this->handleRequest($request);
$response->send();
}
public function withServiceProviders(array $serviceProviders): Application
{
$this->serviceProviders = $serviceProviders;
return $this;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Openguru\OpenCartFramework\Cache;
interface CacheInterface
{
public function get(string $key);
public function set(string $key, $value, ?int $ttlSeconds = null): void;
public function delete(string $key): void;
public function clear(): void;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Openguru\OpenCartFramework\Cache;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
class CacheServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(CacheInterface::class, function (Container $container) {
return new DatabaseCache(
$container->get(ConnectionInterface::class),
$container->get(Settings::class)->get('tables.cache'),
);
});
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Openguru\OpenCartFramework\Cache;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\BulkProducts\Modules\Shared\Cache\CacheInterface;
class DatabaseCache implements CacheInterface
{
private $connection;
private $table;
public function __construct(ConnectionInterface $connection, string $table)
{
$this->connection = $connection;
$this->table = $table;
}
public function get(string $key)
{
$cache = $this->connection->select(
"
SELECT result
FROM $this->table
WHERE cache_key = :key AND (expires_at IS NULL OR expires_at > NOW())
",
[':key' => $key]
);
return $cache ? unserialize($cache[0]['result'], ['allowed_classes' => true]) : null;
}
public function set(string $key, $value, ?int $ttlSeconds = null): void
{
$this->connection->statement("DELETE FROM $this->table WHERE expires_at IS NOT NULL AND expires_at <= NOW()");
$expiresAt = $ttlSeconds ? date('Y-m-d H:i:s', time() + $ttlSeconds) : null;
$sql = <<<SQL
INSERT INTO $this->table (cache_key, result, expires_at)
VALUES (:key, :result, :expiresAt)
ON DUPLICATE KEY UPDATE
result = VALUES(result),
expires_at = VALUES(expires_at)
SQL;
$this->connection->statement($sql, [
':key' => $key,
':result' => serialize($value),
':expiresAt' => $expiresAt,
]);
}
public function delete(string $key): void
{
$this->connection->statement("DELETE FROM $this->table WHERE cache_key = :key", [':key' => $key]);
}
public function clear(): void
{
$this->connection->truncateTable($this->table);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Openguru\OpenCartFramework\Config;
use Openguru\OpenCartFramework\Support\Arr;
class Settings
{
private $settings;
public function __construct(array $initialSettings = [])
{
$this->settings = $initialSettings;
}
public function get(string $key, $default = null)
{
return Arr::get($this->settings, $key, $default);
}
public function set(string $key, $value): void
{
Arr::set($this->settings, $key, $value);
}
public function has(string $key): bool
{
return Arr::get($this->settings, $key) !== null;
}
public function remove(string $key): void
{
Arr::unset($this->settings, $key);
}
public function getAll(): array
{
return $this->settings;
}
public function setAll(array $settings): void
{
$this->settings = $settings;
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace Openguru\OpenCartFramework\Container;
use Openguru\OpenCartFramework\Events\EventDispatcher;
use Openguru\OpenCartFramework\Exceptions\ContainerDependencyResolutionException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Support\Arr;
use Phar;
use Psr\Container\ContainerInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionNamedType;
use RuntimeException;
if (!defined('BP_BASE_PATH')) {
$phar = Phar::running(false);
define('BP_BASE_PATH', $phar ? "phar://$phar" : dirname(__DIR__) . '/..');
}
class Container implements ContainerInterface
{
private array $factories = [];
private array $instances = [];
private array $config;
private $taggedAbstracts = [];
public function __construct(array $config)
{
$this->config = $config;
}
public function getConfigValue(?string $key = null, $default = null)
{
if ($key === null) {
return $this->config;
}
return Arr::get($this->config, $key, $default);
}
public function has(string $id): bool
{
return array_key_exists($id, $this->factories);
}
public function bind(string $abstract, callable $concrete, bool $singleton = false): void
{
$this->factories[$abstract] = [
'concrete' => $concrete,
'singleton' => $singleton,
];
}
public function singleton(string $abstract, callable $concrete): void
{
$this->bind($abstract, $concrete, true);
}
/**
* @template T
* @param string $id
* @return T
* @psalm-param class-string<T>|string $id
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function get(string $id)
{
try {
if ($this->has($id)) {
if ($this->factories[$id]['singleton']) {
if (!array_key_exists($id, $this->instances)) {
$this->instances[$id] = $this->factories[$id]['concrete']($this);
}
return $this->instances[$id];
}
return $this->factories[$id]['concrete']($this);
}
if (class_exists($id)) {
$reflection = new ReflectionClass($id);
$constructor = $reflection->getConstructor();
$arguments = [];
if ($constructor && $parameters = $constructor->getParameters()) {
foreach ($parameters as $parameter) {
if ($parameter->getType() instanceof ReflectionNamedType) {
$parameterType = $parameter->getType()->getName();
/** @psalm-suppress ArgumentTypeCoercion */
$arguments[] = $this->get($parameterType);
} else {
$arguments[] = null;
}
}
}
/** @var T $instance */
$instance = $reflection->newInstanceArgs($arguments);
return $instance;
}
} catch (ReflectionException $exception) {
throw new ContainerDependencyResolutionException(
'Could not resolve the concrete: ' . $id . '. ' . $exception->getMessage()
);
}
throw new ContainerDependencyResolutionException(
'Could not resolve the concrete: ' . $id . ' (class does not exist)'
);
}
/**
* @param class-string $abstract
* @throws ReflectionException
*/
public function call(string $abstract, string $method): JsonResponse
{
if (!class_exists($abstract)) {
throw new ContainerDependencyResolutionException('Could not resolve the concrete: ' . $abstract);
}
if (!method_exists($abstract, $method)) {
throw new ContainerDependencyResolutionException('Method not found: ' . $abstract . '@' . $method);
}
$instance = $this->get($abstract);
$reflection = new ReflectionMethod($instance, $method);
$arguments = [];
foreach ($reflection->getParameters() as $parameter) {
if ($parameter->getType() instanceof ReflectionNamedType) {
$parameterType = $parameter->getType()->getName();
/** @psalm-suppress ArgumentTypeCoercion */
$arguments[] = $this->get($parameterType);
} else {
$arguments[] = null;
}
}
return $instance->{$method}(...$arguments);
}
public function registerEventListener(string $eventClass, string $listenerClass): void
{
$dispatcher = $this->get(EventDispatcher::class);
$dispatcher->registerListener($eventClass, $listenerClass);
}
public function initializeEventDispatcher(array $listeners): void
{
$this->singleton(EventDispatcher::class, function (Container $container) {
return new EventDispatcher($container);
});
foreach ($listeners as $eventClass => $listenerClasses) {
foreach ($listenerClasses as $listenerClass) {
$this->registerEventListener($eventClass, $listenerClass);
}
}
}
public function tag(string $tag, array $abstracts): void
{
if (array_key_exists($tag, $this->taggedAbstracts)) {
throw new RuntimeException('Tag already exists: ' . $tag);
}
$this->taggedAbstracts[$tag] = $abstracts;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Openguru\OpenCartFramework\Container;
abstract class ServiceProvider
{
/** @var Container $container */
protected $container;
public function __construct(Container $container)
{
$this->container = $container;
}
abstract public function register(): void;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Openguru\OpenCartFramework\Contracts;
interface Arrayable
{
/**
* Convert the object to an array.
*
* @return array
*/
public function toArray(): array;
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Openguru\OpenCartFramework;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Settings\DatabaseUserSettings;
use Openguru\OpenCartFramework\Settings\UserSettingsInterface;
use RuntimeException;
class DependencyRegistration
{
public static function register(Container $container, array $serviceProviders = []): void
{
$container->singleton(UserSettingsInterface::class, function (Container $container) {
return $container->get(DatabaseUserSettings::class);
});
static::registerServiceProviders($container, $serviceProviders);
}
private static function registerServiceProviders(Container $container, array $serviceProviders): void
{
foreach ($serviceProviders as $serviceProvider) {
$provider = $container->get($serviceProvider);
if (!$provider instanceof ServiceProvider) {
throw new RuntimeException('ServiceProvider must extend ServiceProvider');
}
$provider->register();
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Openguru\OpenCartFramework;
use ErrorException;
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\Logger\Logger;
use Throwable;
/**
* @codeCoverageIgnore
*/
class ErrorHandler
{
private $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function register(): void
{
set_error_handler([$this, 'handleError']);
set_exception_handler([$this, 'handleException']);
register_shutdown_function([$this, 'handleShutdown']);
}
public function handleError(int $severity, string $message, string $file, int $line): bool
{
$this->logger->error('Handled PHP error: ' . implode(', ', compact('severity', 'message', 'file', 'line')));
// Convert warnings and notices to ErrorException
if (!(error_reporting() & $severity)) {
return true;
}
throw new ErrorException($message, 0, $severity, $file, $line);
}
public function handleException(Throwable $exception): void
{
if (!$exception instanceof NonLoggableExceptionInterface) {
$this->logger->logException($exception);
}
if (PHP_SAPI === 'cli') {
echo $exception->getMessage() . PHP_EOL;
} else {
(new JsonResponse([
'exception' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace(),
], Response::HTTP_INTERNAL_SERVER_ERROR))->send();
}
exit(1);
}
public function handleShutdown(): void
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
$message = sprintf(
"[%s] Fatal error: %s in %s:%d",
date('Y-m-d H:i:s'),
$error['message'],
$error['file'],
$error['line']
);
$this->logger->error($message);
if (PHP_SAPI === 'cli') {
echo $message . PHP_EOL;
} else {
(new JsonResponse([
'message' => $message,
'file' => $error['file'],
'line' => $error['line'],
], Response::HTTP_INTERNAL_SERVER_ERROR))->send();
}
exit(1);
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Openguru\OpenCartFramework\Events;
interface Event
{
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Openguru\OpenCartFramework\Events;
use Openguru\OpenCartFramework\Container\Container;
class EventDispatcher
{
private $listeners = [];
private $container;
public function __construct(Container $container)
{
$this->container = $container;
}
public function registerListener(string $eventClass, string $listenerClass): void
{
$this->listeners[$eventClass][] = $listenerClass;
}
public function dispatch(Event $event): void
{
$eventClass = get_class($event);
if (isset($this->listeners[$eventClass])) {
foreach ($this->listeners[$eventClass] as $listenerClass) {
$listener = $this->container->get($listenerClass);
$listener->handle($event);
}
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Openguru\OpenCartFramework\Events;
interface Listener
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\Exceptions;
use Exception;
class ActionNotFoundException extends Exception implements NonLoggableExceptionInterface
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\Exceptions;
use Exception;
class ApplicationNotInstalledException extends Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\Exceptions;
use RuntimeException;
class ContainerDependencyResolutionException extends RuntimeException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Openguru\OpenCartFramework\Exceptions;
interface NonLoggableExceptionInterface
{
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Openguru\OpenCartFramework\Http;
class JsonResponse extends Response
{
private array $data;
private int $code;
public function __construct(array $data, int $code = self::HTTP_OK)
{
$this->data = $data;
$this->code = $code;
}
public function getData(): array
{
return $this->data;
}
public function getCode(): int
{
return $this->code;
}
public function send(): void
{
$this->sendHeaders();
$this->sendContent();
}
private function sendContent(): void
{
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
echo json_encode($this->getData(), JSON_THROW_ON_ERROR);
}
private function sendHeaders(): void
{
http_response_code($this->getCode());
header('Content-Type: application/json');
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Openguru\OpenCartFramework\Http;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\Utils;
class Request
{
private $query;
private $request;
private $cookies;
private $files;
private $server;
private $content;
public function __construct(
array $query,
array $request,
array $cookies,
array $files,
array $server,
string $content = null
) {
$this->query = $query;
$this->request = $request;
$this->cookies = $cookies;
$this->files = $files;
$this->server = $server;
$this->content = $content;
}
public static function createFromGlobals(): Request
{
return new static($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
}
public function getContent(): string
{
if ($this->content === null || $this->content === '') {
$this->content = (string)file_get_contents('php://input');
}
return $this->content;
}
public function json(string $key = null, $default = null)
{
$content = $this->getContent();
if (!$content) {
return $default;
}
$decoded = Utils::safeJsonDecode($content);
if ($key === null) {
return $decoded;
}
return Arr::get($decoded, $key, $default);
}
public function get(string $key = null, $default = null)
{
if ($key === null) {
return $this->query;
}
return $this->query[$key] ?? $default;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Openguru\OpenCartFramework\Http;
class Response
{
public const HTTP_CONTINUE = 100;
public const HTTP_SWITCHING_PROTOCOLS = 101;
public const HTTP_PROCESSING = 102; // RFC2518
public const HTTP_EARLY_HINTS = 103; // RFC8297
public const HTTP_OK = 200;
public const HTTP_CREATED = 201;
public const HTTP_ACCEPTED = 202;
public const HTTP_NON_AUTHORITATIVE_INFORMATION = 203;
public const HTTP_NO_CONTENT = 204;
public const HTTP_RESET_CONTENT = 205;
public const HTTP_PARTIAL_CONTENT = 206;
public const HTTP_MULTI_STATUS = 207; // RFC4918
public const HTTP_ALREADY_REPORTED = 208; // RFC5842
public const HTTP_IM_USED = 226; // RFC3229
public const HTTP_MULTIPLE_CHOICES = 300;
public const HTTP_MOVED_PERMANENTLY = 301;
public const HTTP_FOUND = 302;
public const HTTP_SEE_OTHER = 303;
public const HTTP_NOT_MODIFIED = 304;
public const HTTP_USE_PROXY = 305;
public const HTTP_RESERVED = 306;
public const HTTP_TEMPORARY_REDIRECT = 307;
public const HTTP_PERMANENTLY_REDIRECT = 308; // RFC7238
public const HTTP_BAD_REQUEST = 400;
public const HTTP_UNAUTHORIZED = 401;
public const HTTP_PAYMENT_REQUIRED = 402;
public const HTTP_FORBIDDEN = 403;
public const HTTP_NOT_FOUND = 404;
public const HTTP_METHOD_NOT_ALLOWED = 405;
public const HTTP_NOT_ACCEPTABLE = 406;
public const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;
public const HTTP_REQUEST_TIMEOUT = 408;
public const HTTP_CONFLICT = 409;
public const HTTP_GONE = 410;
public const HTTP_LENGTH_REQUIRED = 411;
public const HTTP_PRECONDITION_FAILED = 412;
public const HTTP_REQUEST_ENTITY_TOO_LARGE = 413;
public const HTTP_REQUEST_URI_TOO_LONG = 414;
public const HTTP_UNSUPPORTED_MEDIA_TYPE = 415;
public const HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416;
public const HTTP_EXPECTATION_FAILED = 417;
public const HTTP_I_AM_A_TEAPOT = 418; // RFC2324
public const HTTP_MISDIRECTED_REQUEST = 421; // RFC7540
public const HTTP_UNPROCESSABLE_ENTITY = 422; // RFC4918
public const HTTP_LOCKED = 423; // RFC4918
public const HTTP_FAILED_DEPENDENCY = 424; // RFC4918
public const HTTP_TOO_EARLY = 425; // RFC-ietf-httpbis-replay-04
public const HTTP_UPGRADE_REQUIRED = 426; // RFC2817
public const HTTP_PRECONDITION_REQUIRED = 428; // RFC6585
public const HTTP_TOO_MANY_REQUESTS = 429; // RFC6585
public const HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585
public const HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451; // RFC7725
public const HTTP_INTERNAL_SERVER_ERROR = 500;
public const HTTP_NOT_IMPLEMENTED = 501;
public const HTTP_BAD_GATEWAY = 502;
public const HTTP_SERVICE_UNAVAILABLE = 503;
public const HTTP_GATEWAY_TIMEOUT = 504;
public const HTTP_VERSION_NOT_SUPPORTED = 505;
public const HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295
public const HTTP_INSUFFICIENT_STORAGE = 507; // RFC4918
public const HTTP_LOOP_DETECTED = 508; // RFC5842
public const HTTP_NOT_EXTENDED = 510; // RFC2774
public const HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Openguru\OpenCartFramework\Logger;
use Throwable;
class Logger
{
private $logFile;
private $logLevel;
private $maxFileSize; // Максимальный размер файла в байтах
public const LEVEL_INFO = 1;
public const LEVEL_WARNING = 2;
public const LEVEL_ERROR = 3;
public function __construct($logFile, $logLevel = self::LEVEL_INFO, $maxFileSize = 1048576)
{
$this->logFile = $logFile;
$this->logLevel = $logLevel;
$this->maxFileSize = $maxFileSize;
}
public function log($message, $level = self::LEVEL_INFO)
{
if ($level < $this->logLevel) {
return; // Не логируем, если уровень ниже установленного
}
$this->rotateLogs();
$timestamp = date('Y-m-d H:i:s');
$levelStr = $this->getLevelString($level);
$logMessage = "[$timestamp] [$levelStr] $message\n";
file_put_contents($this->logFile, $logMessage, FILE_APPEND);
}
private function getLevelString($level): string
{
switch ($level) {
case self::LEVEL_INFO:
return 'INFO';
case self::LEVEL_WARNING:
return 'WARNING';
case self::LEVEL_ERROR:
return 'ERROR';
default:
return 'UNKNOWN';
}
}
public function info(string $message): void
{
$this->log($message, self::LEVEL_INFO);
}
public function warning(string $message): void
{
$this->log($message, self::LEVEL_WARNING);
}
public function error(string $message): void
{
$this->log($message, self::LEVEL_ERROR);
}
private function rotateLogs(): void
{
if (is_file($this->logFile) && filesize($this->logFile) >= $this->maxFileSize) {
$newLogFile = $this->logFile . '.' . date('YmdHis');
rename($this->logFile, $newLogFile);
}
}
public function logException(Throwable $exception): void
{
$this->error(
sprintf(
"Fatal error %s in %s on line %d\n%s",
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
$exception->getTraceAsString()
)
);
}
}

View File

@@ -0,0 +1,429 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder;
use Closure;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\QueryBuilder\Grammars\Grammar;
use Openguru\OpenCartFramework\Support\Utils;
use InvalidArgumentException;
class Builder
{
private $connection;
private $grammar;
public $columns = '*';
public $from;
public $joins = [];
public $wheres = [];
public $orders = [];
public $limit;
public $offset;
public $distinct = false;
public $bindings = [
'select' => [],
'from' => [],
'join' => [],
'where' => [],
'order' => [],
];
public function __construct(ConnectionInterface $connection, Grammar $grammar)
{
$this->connection = $connection;
$this->grammar = $grammar;
}
public function newQuery(): Builder
{
return new self($this->getConnection(), $this->getGrammar());
}
public function select(array $columns): Builder
{
$this->columns = [];
$this->bindings['select'] = [];
if (!$columns) {
throw new InvalidArgumentException('Select columns not provided');
}
foreach ($columns as $key => $value) {
if ($value instanceof RawExpression) {
$this->columns[] = $value;
continue;
}
if (is_string($key)) {
$this->columns[] = [
'column' => $key,
'as' => $value,
];
continue;
}
$this->columns[] = [
'column' => $value,
'as' => null,
];
}
return $this;
}
public function from(string $table, ?string $as = null): Builder
{
$this->from = compact('table', 'as');
return $this;
}
public function whereNull($column, $boolean = 'and'): Builder
{
$this->wheres[] = [
'type' => 'Null',
'column' => $column,
'boolean' => $boolean,
];
return $this;
}
public function whereNotNull($column, $boolean = 'and'): Builder
{
$this->wheres[] = [
'type' => 'NotNull',
'column' => $column,
'boolean' => $boolean,
];
return $this;
}
public function whereNested(Closure $callback, $boolean = 'and'): Builder
{
$callback($nestedQuery = $this->newQuery());
$this->wheres[] = [
'type' => 'Nested',
'query' => $nestedQuery,
'boolean' => $boolean,
];
$this->bindings['where'] = array_merge(
$this->bindings['where'],
$nestedQuery->getBindings('where')
);
return $this;
}
public function whereRaw(string $expression, $boolean = 'and'): Builder
{
$this->wheres[] = [
'type' => 'Raw',
'expression' => $expression,
'boolean' => $boolean,
];
return $this;
}
public function where($column, $operator, $value, $boolean = 'and'): Builder
{
$this->wheres[] = [
'type' => 'Basic',
'column' => $column,
'operator' => $operator,
'value' => $value,
'boolean' => $boolean,
];
$this->addBinding($value);
return $this;
}
public function whereIn($column, array $values, $not = false, $boolean = 'and'): Builder
{
$this->wheres[] = [
'type' => 'In',
'column' => $column,
'operator' => $not ? 'NOT IN' : 'IN',
'value' => $values,
'boolean' => $boolean,
];
$this->addBinding($values);
return $this;
}
public function whereNotIn($column, array $values, $boolean = 'and'): Builder
{
$this->wheres[] = [
'type' => 'In',
'column' => $column,
'operator' => 'NOT IN',
'value' => $values,
'boolean' => $boolean,
];
$this->addBinding($values);
return $this;
}
public function whereBetween(string $column, array $values, $boolean = 'and'): Builder
{
if (count($values) !== 2) {
throw new InvalidArgumentException('Invalid number of values provided.');
}
$this->wheres[] = [
'type' => 'Between',
'column' => $column,
'values' => $values,
'boolean' => $boolean,
];
$this->addBinding($values[0]);
$this->addBinding($values[1]);
return $this;
}
public function orWhere($column, $operator, $value): Builder
{
return $this->where($column, $operator, $value, 'or');
}
public function limit(int $limit): Builder
{
$this->limit = $limit;
return $this;
}
public function offset(int $offset): Builder
{
$this->offset = $offset;
return $this;
}
public function forPage(int $page = 0, int $perPage = 10): Builder
{
$offset = ($page - 1) * $perPage;
return $this->offset($offset)->limit($perPage);
}
public function orderBy(string $column, string $direction = 'ASC'): Builder
{
$direction = strtoupper($direction);
if (!in_array($direction, ['ASC', 'DESC'], true)) {
throw new InvalidArgumentException('Order direction must be "ASC" or "DESC".');
}
$this->orders[] = [
'column' => $column,
'direction' => $direction,
];
return $this;
}
public function toSql(): string
{
return $this->grammar->compileComponents($this);
}
public function toRawSql(): string
{
return $this->substituteBindingsIntoRawSql(
$this->toSql(),
$this->getBindings()
);
}
public function substituteBindingsIntoRawSql(string $sql, array $bindings): string
{
$bindings = array_map(function ($value) {
return $this->connection->escape($value);
}, $bindings);
$query = '';
$isStringLiteral = false;
$sqlLen = strlen($sql);
for ($i = 0; $i < $sqlLen; $i++) {
$char = $sql[$i];
$nextChar = $sql[$i + 1] ?? null;
// Single quotes can be escaped as '' according to the SQL standard while
// MySQL uses \'. Postgres has operators like ?| that must get encoded
// in PHP like ??|. We should skip over the escaped characters here.
if (in_array($char . $nextChar, ["\'", "''", '??'])) {
$query .= $char . $nextChar;
++$i;
} elseif ($char === "'") { // Starting / leaving string literal...
$query .= $char;
$isStringLiteral = !$isStringLiteral;
} elseif ($char === '?' && !$isStringLiteral) { // Substitutable binding...
$query .= array_shift($bindings) ?? '?';
} else { // Normal character...
$query .= $char;
}
}
return $query;
}
public function get(): array
{
return $this->runSelect();
}
/**
* @return array|null
*/
public function firstOrNull(): ?array
{
$rows = $this->limit(1)->get();
return $rows[0] ?? null;
}
public function value(string $field)
{
$rows = $this->limit(1)->get();
return $rows[0][$field] ?? null;
}
public function pluck(string $column, string $index = null): array
{
$items = $this->runSelect();
return array_column($items, $column, $index);
}
public function storeResultsToTable(string $destinationTable): bool
{
$sql = "INSERT INTO `$destinationTable` {$this->toSql()}";
$this->connection->truncateTable($destinationTable);
return $this->connection->statement($sql, $this->getBindings());
}
public function runSelect(): array
{
return $this->connection->select(
$this->toSql(),
$this->getBindings()
);
}
public function getBindings(string $type = null): array
{
if ($type === null) {
return Utils::arrayFlatten($this->bindings);
}
return $this->bindings[$type];
}
public function addBinding($value, $type = 'where'): Builder
{
if (!array_key_exists($type, $this->bindings)) {
throw new InvalidArgumentException("Invalid binding type: {$type}.");
}
$this->bindings[$type][] = $value;
return $this;
}
public function dd(): void
{
Utils::dd($this->toSql(), $this->getBindings());
}
public function join(string $table, Closure $joinClosure, $type = 'inner'): Builder
{
$joinClause = new JoinClause($this, $type, $table);
$joinClosure($joinClause);
$this->joins[] = $joinClause;
$this->bindings['join'][] = $joinClause->getBindings();
return $this;
}
public function leftJoin(string $table, Closure $joinClosure): Builder
{
return $this->join($table, $joinClosure, 'left');
}
public function getConnection(): ConnectionInterface
{
return $this->connection;
}
public function getGrammar(): Grammar
{
return $this->grammar;
}
public function count(): int
{
$sql = 'SELECT COUNT(*) AS total_count FROM (' . $this->toSql() . ') AS x';
$result = $this->connection->select($sql, $this->getBindings());
return $result[0]['total_count'] ?? 0;
}
public function distinct(): Builder
{
$this->distinct = true;
return $this;
}
/**
* Apply a callback to the query if the given condition is true.
*
* @param bool $condition
* @param Closure $callback
* @param Closure|null $default
* @return $this
*/
public function when(bool $condition, Closure $callback, Closure $default = null): Builder
{
if ($condition) {
$callback($this);
} elseif ($default) {
$default($this);
}
return $this;
}
public function hasJoinAlias(string $joinAlias): bool
{
$join = array_filter($this->joins, static function (JoinClause $join) use ($joinAlias) {
return strpos($join->table, $joinAlias) !== false;
});
return count($join) > 0;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder\Connections;
interface ConnectionInterface
{
public function select(string $sql, array $bindings = []): array;
public function createTableFromSql(string $table, string $sql, array $bindings = []): bool;
public function insertIntoTableFromSql(string $table, string $sql, array $bindings = []): bool;
public function dropTable(string $table): bool;
public function dropTableIfExists(string $table): bool;
public function tableExists(string $table): bool;
public function escape($value): string;
public function statement(string $sql, array $bindings = []): bool;
public function beginTransaction(): bool;
public function commitTransaction(): bool;
public function rollBackTransaction(): bool;
public function truncateTable(string $table): bool;
public function lastInsertId($name = null): int;
public function getVersion(): string;
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder\Connections;
use Openguru\OpenCartFramework\Support\Utils;
use PDO;
use RuntimeException;
class MySqlConnection implements ConnectionInterface
{
private $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec("SET NAMES 'utf8'");
$this->pdo->exec("SET CHARACTER SET utf8");
$this->pdo->exec("SET CHARACTER_SET_CONNECTION=utf8");
$this->pdo->exec("SET SQL_MODE = ''");
}
public function select(string $sql, array $bindings = []): array
{
$statement = $this->pdo->prepare($sql);
$statement->execute($bindings);
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
public function escape($value): string
{
switch (true) {
case $value === null:
return 'null';
case is_int($value) || is_float($value):
return (string)$value;
case is_bool($value):
return $value ? '1' : '0';
case is_array($value):
throw new RuntimeException('The database connection does not support escaping arrays.');
default:
if (Utils::strContains($value, "\00")) {
throw new RuntimeException(
'Strings with null bytes cannot be escaped. Use the binary escape option.'
);
}
if (preg_match('//u', $value) === false) {
throw new RuntimeException('Strings with invalid UTF-8 byte sequences cannot be escaped.');
}
return $this->pdo->quote($value);
}
}
public function createTableFromSql(string $table, string $sql, array $bindings = []): bool
{
$createSql = "CREATE TABLE IF NOT EXISTS `$table` AS ($sql)";
return $this->pdo->prepare($createSql)->execute($bindings);
}
public function dropTable(string $table): bool
{
return $this->pdo->prepare("DROP TABLE `$table`")->execute();
}
public function dropTableIfExists(string $table): bool
{
return $this->pdo->prepare("DROP TABLE IF EXISTS `$table`")->execute();
}
public function tableExists(string $table): bool
{
$statement = $this->pdo->prepare("SHOW TABLES LIKE ?;");
$statement->execute([$table]);
return $statement->rowCount() > 0;
}
public function statement(string $sql, array $bindings = []): bool
{
return $this->pdo->prepare($sql)->execute($bindings);
}
public function beginTransaction(): bool
{
return $this->pdo->beginTransaction();
}
public function commitTransaction(): bool
{
return $this->pdo->commit();
}
public function rollBackTransaction(): bool
{
return $this->pdo->rollBack();
}
public function insertIntoTableFromSql(string $table, string $sql, array $bindings = []): bool
{
$createSql = "INSERT INTO `{$table}` {$sql}";
return $this->pdo->prepare($createSql)->execute($bindings);
}
public function truncateTable(string $table): bool
{
return $this->pdo->prepare("TRUNCATE TABLE {$table}")->execute();
}
public function lastInsertId($name = null): int
{
return $this->pdo->lastInsertId($name);
}
public function getVersion(): string
{
return $this->pdo
->query('SELECT VERSION()')
->fetch(PDO::FETCH_COLUMN);
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder\Grammars;
use BadMethodCallException;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
abstract class Grammar
{
protected $compiled = [
'columns' => [],
'from' => [],
'joins' => [],
'wheres' => [],
'orders' => [],
'limit' => [],
'offset' => [],
];
private function resetCompiled(): void
{
$this->compiled = [
'columns' => [],
'from' => [],
'joins' => [],
'wheres' => [],
'orders' => [],
'limit' => [],
'offset' => [],
];
}
public function compileComponents(Builder $builder): string
{
$this->resetCompiled();
foreach (array_keys($this->compiled) as $component) {
$method = 'compile' . ucfirst($component);
if (!method_exists($this, $method)) {
throw new BadMethodCallException("Method '{$method}' does not exist.");
}
if (! empty($builder->{$component})) {
$this->compiled[$component] = $this->{$method}(
$builder,
$builder->{$component},
);
}
}
return $this->concatCompiled($this->compiled);
}
public function concatCompiled(array $compiled): string
{
$array = [];
foreach (array_keys($this->compiled) as $component) {
$array[] = $compiled[$component] ?? null;
}
return implode(' ', array_filter($array));
}
public function compileColumns(Builder $builder, $columns): string
{
if ($columns === '*' || count($columns) === 0) {
return 'SELECT *';
}
$columns = array_map(static function ($item) {
if ($item instanceof RawExpression) {
return $item->getValue();
}
return $item['column'] . ($item['as'] !== null ? ' AS ' . $item['as'] : '');
}, $columns);
$distinct = $builder->distinct ? 'DISTINCT ' : '';
return "SELECT {$distinct}" . implode(', ', $columns);
}
public function compileWheres(Builder $builder, $wheres, string $prefix = 'WHERE '): string
{
if (!$wheres) {
return '';
}
$compiledConditions = [];
$counter = 0;
foreach ($wheres as $condition) {
$compiledConditions[] = ($counter > 0 ? mb_strtoupper($condition['boolean']) . ' ' : '')
. $this->{'where' . $condition['type']}($condition);
$counter++;
}
return $prefix . implode(' ', $compiledConditions);
}
public function whereBasic($condition): string
{
return $condition['column'] . ' ' . $condition['operator'] . ' ?';
}
public function whereRaw($condition): string
{
return $condition['expression'];
}
public function whereNull($condition): string
{
return $condition['column'] . ' IS NULL';
}
public function whereNotNull($condition): string
{
return $condition['column'] . ' IS NOT NULL';
}
public function whereNested($condition): string
{
return '(' . $this->compileWheres($condition['query'], $condition['query']->wheres, '') . ')';
}
public function whereBetween($condition): string
{
return $condition['column'] . ' BETWEEN ? AND ?';
}
public function compileOrders(Builder $builder, array $orders): string
{
return 'ORDER BY ' . implode(
', ',
array_map(
static function ($order) {
return $order['column'] . ' ' . $order['direction'];
},
$orders
)
);
}
public function compileFrom(Builder $builder, array $from): string
{
return 'FROM ' . $from['table'] . ($from['as'] !== null ? ' AS ' . $from['as'] : '');
}
public function compileLimit(Builder $builder, $limit): string
{
return "LIMIT $limit";
}
public function compileOffset(Builder $builder, $offset): string
{
return "OFFSET $offset";
}
public function compileJoins(Builder $builder, $joins): string
{
return implode(' ', array_map(function (JoinClause $join) use ($builder) {
$conditions = '';
if ($join->wheres) {
$conditions = ' AND ' . $this->compileWheres($builder, $join->wheres, '');
}
return mb_strtoupper(
$join->type
) . " JOIN $join->table ON $join->first $join->operator $join->second" . $conditions;
}, $joins));
}
public function whereIn($condition): string
{
if (! $condition['value']) {
return '';
}
$inValues = str_repeat('?, ', count($condition['value']) - 1) . '?';
$inOperator = $condition['operator'];
return $condition['column'] . " $inOperator (" . $inValues . ')';
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder\Grammars;
class MySqlGrammar extends Grammar
{
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder;
class JoinClause extends Builder
{
public $table;
public $type;
public $first;
public $second;
public $operator;
public $bindings = [
'where' => [],
];
public function __construct(Builder $parent, string $type, string $table)
{
$this->type = $type;
$this->table = $table;
parent::__construct($parent->getConnection(), $parent->getGrammar());
}
public function on($first, $operator, $second): JoinClause
{
$this->first = $first;
$this->operator = $operator;
$this->second = $second;
return $this;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder;
use RuntimeException;
class QueryBuilderException extends RuntimeException
{
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\QueryBuilder\Connections\MySqlConnection;
use Openguru\OpenCartFramework\QueryBuilder\Grammars\MySqlGrammar;
use PDO;
class QueryBuilderServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->bind(ConnectionInterface::class, function (Container $container) {
$host = $container->getConfigValue('db.host');
$username = $container->getConfigValue('db.username');
$password = $container->getConfigValue('db.password');
$port = $container->getConfigValue('db.port');
$dbName = $container->getConfigValue('db.database');
$dsn = "mysql:host=$host;port=$port;dbname=$dbName";
return new MySqlConnection(
new PDO($dsn, $username, $password)
);
});
$this->container->bind(Builder::class, function (Container $container) {
return new Builder(
$container->get(ConnectionInterface::class),
$container->get(MySqlGrammar::class)
);
});
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder;
class QueryResult
{
private $row;
private $rows;
private $numRows;
public function __construct(
$row,
$rows,
$numRows
) {
$this->row = $row;
$this->rows = $rows;
$this->numRows = $numRows;
}
/**
* @return mixed
*/
public function getRow()
{
return $this->row;
}
/**
* @return mixed
*/
public function getRows()
{
return $this->rows;
}
/**
* @return mixed
*/
public function getNumRows(): int
{
return $this->numRows;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Openguru\OpenCartFramework\QueryBuilder;
class RawExpression
{
private $value;
public function __construct(string $value)
{
$this->value = $value;
}
public function getValue(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Openguru\OpenCartFramework\Router;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(Router::class, function (Container $container) {
return new Router();
});
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Openguru\OpenCartFramework\Router;
use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
class Router
{
private array $routes;
public function __construct(array $routes = [])
{
$this->routes = $routes;
}
public function loadRoutesFromFile(string $file): void
{
$this->routes = include $file;
}
/**
* @param $action
* @return string[]
* @throws ActionNotFoundException
*/
public function resolve($action): array
{
if (!$action) {
throw new ActionNotFoundException('No action provided');
}
if (isset($this->routes[$action])) {
return $this->routes[$action];
}
throw new ActionNotFoundException('Action "' . $action . '" not found.');
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Openguru\OpenCartFramework\Settings;
use Exception;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use JsonException;
use RuntimeException;
class DatabaseUserSettings implements UserSettingsInterface
{
private $builder;
private $database;
private $allSettings = [];
public function __construct(Builder $builder, ConnectionInterface $database)
{
$this->builder = $builder;
$this->database = $database;
}
public function getAll(): array
{
if ($this->allSettings) {
return $this->allSettings;
}
$row = $this->builder
->select(['settings_json'])
->from(config('tables.settings'))
->where('id', '=', 1)
->firstOrNull();
if (! $row || empty($row['settings_json'])) {
throw new RuntimeException('Database settings not found. Please reinstall the module.');
}
try {
$decoded = json_decode($row['settings_json'], true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException('Database settings invalid format: ' . $e->getMessage());
}
$this->allSettings = $decoded;
return $decoded;
}
public function update(array $userSettings): void
{
$settingsTable = config('tables.settings');
try {
$currentSettings = $this->getAll();
$updatedSettings = array_replace_recursive($currentSettings, $userSettings);
$encoded = json_encode($updatedSettings, JSON_THROW_ON_ERROR);
$sql = <<<SQL
INSERT INTO `{$settingsTable}`
(id, settings_json)
VALUES
(1, '{$encoded}')
ON DUPLICATE KEY UPDATE
settings_json = VALUES(settings_json)
SQL;
$this->database->statement($sql, [$encoded]);
} catch (Exception $e) {
throw new RuntimeException('Could not update settings: ' . $e->getMessage());
}
}
public function get(string $key, $default = null)
{
$all = $this->getAll();
return Arr::get($all, $key, $default);
}
public function safeGet(string $key, $default = null)
{
try {
return $this->get($key, $default);
} catch (Exception $e) {
return $default;
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Openguru\OpenCartFramework\Settings;
interface UserSettingsInterface
{
public function get(string $key, $default = null);
public function getAll(): array;
public function update(array $userSettings): void;
public function safeGet(string $key, $default = null);
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Openguru\OpenCartFramework\Support;
use InvalidArgumentException;
class Arr
{
/**
* Transform an array to use a specified field as keys.
*
* @param array $array Input array of items.
* @param string $keyField Field name to be used as keys.
* @return array Transformed array with specified field as keys.
* @throws InvalidArgumentException If the key field is missing in any item.
*/
public static function keyByField(array $array, string $keyField): array
{
$result = [];
foreach ($array as $item) {
if (!array_key_exists($keyField, $item)) {
throw new InvalidArgumentException("Key field '{$keyField}' is missing in one of the items.");
}
$result[$item[$keyField]] = $item;
}
return $result;
}
/**
* Group array by a specific key and collect values into sub-arrays.
*
* @param array $data Array to group.
* @param string $groupKey Key to group by.
* @param string $valueKey Key to collect values from.
* @return array Grouped array.
*/
public static function groupByKey(array $data, string $groupKey, string $valueKey): array
{
$result = [];
foreach ($data as $item) {
$result[$item[$groupKey]][] = $item[$valueKey];
}
return $result;
}
public static function get(array $items, string $key, $default = null)
{
if (!$items) {
return $default;
}
if (array_key_exists($key, $items)) {
return $items[$key];
}
$segments = explode('.', $key);
foreach ($segments as $segment) {
if (!is_array($items) || !array_key_exists($segment, $items)) {
return $default;
}
$items = &$items[$segment];
}
return $items;
}
public static function set(array &$array, string $key, $value): void
{
$keys = explode('.', $key);
foreach ($keys as $k) {
if (!isset($array[$k]) || !is_array($array[$k])) {
$array[$k] = [];
}
$array = &$array[$k];
}
$array = $value;
}
public static function unset(array &$array, string $key): void
{
$keys = explode('.', $key);
while (count($keys) > 1) {
$k = array_shift($keys);
if (!isset($array[$k]) || !is_array($array[$k])) {
return;
}
$array = &$array[$k];
}
unset($array[array_shift($keys)]);
}
public static function find(array $array, callable $callback)
{
foreach ($array as $key => $value) {
if ($callback($value, $key) === true) {
return $value;
}
}
return null;
}
public static function pluck(array $value, string $field): array
{
return array_map(
static function ($item) use ($field) {
if (is_object($item)) {
return $item->{$field};
}
return $item[$field];
},
$value
);
}
public static function mergeArraysRecursively(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (isset($base[$key]) && is_array($base[$key]) && is_array($value)) {
$base[$key] = static::mergeArraysRecursively($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Openguru\OpenCartFramework\Support;
class ExecutionTimeProfiler
{
private $startTime;
private $checkpoints = [];
public function start(): void
{
$this->startTime = microtime(true);
$this->checkpoints = [['label' => 'start', 'time' => $this->startTime]];
}
public function addCheckpoint(string $label): void
{
$currentTime = microtime(true);
$this->checkpoints[] = [
'label' => $label,
'time' => $currentTime
];
}
public function stop(): array
{
$endTime = microtime(true);
$this->checkpoints[] = ['label' => 'End', 'time' => $endTime];
$result = [
'total_execution_time_ms' => round(($endTime - $this->startTime) * 1000, 2),
'checkpoints' => []
];
$total = count($this->checkpoints);
for ($i = 1; $i < $total; $i++) {
$prev = $this->checkpoints[$i - 1];
$current = $this->checkpoints[$i];
$result['checkpoints'][] = [
'label' => $current['label'],
'elapsed_since_previous_ms' => round(($current['time'] - $prev['time']) * 1000, 2)
];
}
return $result;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Openguru\OpenCartFramework\Support;
class PaginationHelper
{
public static function calculateLastPage(int $totalRecords, int $perPage): int
{
return (int)ceil($totalRecords / $perPage);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Openguru\OpenCartFramework\Support;
use InvalidArgumentException;
class Utils
{
/**
* @return array|string[]
*/
public static function ucsplit(string $string): array
{
return preg_split('/(?=\p{Lu})/u', $string, -1, PREG_SPLIT_NO_EMPTY) ?? [];
}
public static function safeJsonDecode(string $content)
{
$decoded = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new InvalidArgumentException('JSON error: ' . json_last_error_msg());
}
return $decoded;
}
/**
* @psalm-return list<mixed>
*/
public static function arrayFlatten(array $array, $depth = INF): array
{
$result = [];
foreach ($array as $item) {
if (! is_array($item)) {
$result[] = $item;
} else {
$values = $depth === 1
? array_values($item)
: static::arrayFlatten($item, $depth - 1);
foreach ($values as $value) {
$result[] = $value;
}
}
}
return $result;
}
/**
* @return never
*/
public static function dd()
{
$args = func_get_args();
echo '<pre>';
/** @psalm-suppress ForbiddenCode */
var_dump(...$args);
echo '</pre>';
die();
}
public static function strContains(string $haystack, string $needle): bool
{
return $needle === '' || strpos($haystack, $needle) !== false;
}
}

View File

@@ -0,0 +1,59 @@
<?php
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Support\Utils;
if (!function_exists('table')) {
function db_table(string $name): string
{
$prefix = Application::getInstance()->getConfigValue('db.prefix');
return $prefix . $name;
}
}
if (!function_exists('column')) {
function db_column($column): string
{
if (strpos($column, '.') !== false) {
[$table, $column] = explode('.', $column, 2);
if ($table === '' || $column === '') {
throw new InvalidArgumentException('Invalid column reference: ' . $column);
}
return db_table($table) . '.' . $column;
}
return $column;
}
}
if (!function_exists('resources_path')) {
function resources_path(string $path = ''): string
{
return BP_BASE_PATH . '/resources/' . $path;
}
}
if (!function_exists('base_path')) {
function base_path(string $path = ''): string
{
return BP_BASE_PATH . '/' . $path;
}
}
if (!function_exists('config')) {
function config(string $key, $default = null)
{
return Application::getInstance()->get(Settings::class)->get($key, $default);
}
}
if (! function_exists('dd')) {
function dd(): void
{
Utils::dd(func_get_args());
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Openguru\OpenCartFramework\Translator;
use RuntimeException;
class Translator implements TranslatorInterface
{
private $language;
private $translations;
public function __construct(string $language, array $translations = [])
{
$this->language = $language;
$this->translations = $translations;
}
public function loadTranslationsFromFolder(string $directory): void
{
if (!is_dir($directory)) {
throw new RuntimeException('Translations folder not found.');
}
$filename = $directory . "/{$this->language}.php";
if (!is_file($filename)) {
throw new RuntimeException("Translation file for language '{$this->language}' not found.");
}
$translations = include $filename;
$this->translations = array_merge($this->translations, $translations);
}
public function translate(string $key, array $params = []): string
{
if (isset($this->translations[$key])) {
return $this->substitute($this->translations[$key], $params);
}
return $key;
}
public function getTranslations(): array
{
return $this->translations;
}
public function getLanguage(): string
{
return $this->language;
}
private function substitute($text, array $parameters): string
{
if (!$parameters) {
return $text;
}
$search = array_map(static function ($param) {
return '{' . $param . '}';
}, array_keys($parameters));
return str_replace($search, array_values($parameters), $text);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Openguru\OpenCartFramework\Translator;
interface TranslatorInterface
{
public function loadTranslationsFromFolder(string $directory): void;
public function translate(string $key, array $params = []): string;
public function getTranslations(): array;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Openguru\OpenCartFramework\Translator;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
class TranslatorServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(TranslatorInterface::class, function (Container $container) {
$language = $container->getConfigValue('lang');
$translator = new Translator($language);
$translator->loadTranslationsFromFolder(resources_path('/translations'));
return $translator;
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Openguru\OpenCartFramework\Validator;
class Validator
{
private $input;
private $rules;
public function __construct(array $input, array $rules)
{
$this->input = $input;
$this->rules = $rules;
}
public function validate(): bool
{
foreach ($this->rules as $name => $rule) {
$components = explode('|', $rule);
}
return true;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App;
use App\ServiceProviders\AppServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
use Openguru\OpenCartFramework\Support\Arr;
class ApplicationFactory
{
public static function create(array $config): Application
{
$defaultConfig = require __DIR__ . '/config.php';
return (new Application(Arr::mergeArraysRecursively($defaultConfig, $config)))
->withServiceProviders([
QueryBuilderServiceProvider::class,
CacheServiceProvider::class,
RouteServiceProvider::class,
AppServiceProvider::class
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Handlers;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class HelloWorldHandler
{
private Builder $queryBuilder;
public function __construct(Builder $queryBuilder)
{
$this->queryBuilder = $queryBuilder;
}
public function handle(): JsonResponse
{
$languageId = 1;
$products = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.quantity' => 'product_quantity',
'product_description.name' => 'product_name',
'products.price' => 'product_price',
])
->from(db_table('product'), 'products')
->join(
db_table('product_description') . ' AS product_description',
function (JoinClause $join) use ($languageId) {
$join->on('products.product_id', '=', 'product_description.product_id')
->where('product_description.language_id', '=', $languageId);
}
)
->get();
return new JsonResponse([
'data' => array_map(function ($product) {
return [
'product_id' => (int) $product['product_id'],
'product_quantity' => (int) $product['product_quantity'],
'product_name' => $product['product_name'],
'product_price' => (float) $product['product_price'],
];
}, $products),
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Handlers;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
class OrderCreateHandler
{
private ConnectionInterface $database;
public function __construct(ConnectionInterface $database)
{
$this->database = $database;
}
public function handle(Request $request): JsonResponse
{
$now = date('Y-m-d H:i:s');
$storeId = 0;
$storeName = 'Ваш магазин';
$sql = <<<SQL
INSERT INTO oc_order
(
store_id,
store_name,
firstname,
lastname,
shipping_code,
total,
order_status_id,
currency_code,
currency_value,
date_added,
date_modified
)
VALUES
(
$storeId,
'$storeName',
'John',
'Doe',
'flat.flat',
99.9999,
1,
'RUB',
1,
'$now',
'$now'
)
SQL;
$result = $this->database->statement($sql);
return new JsonResponse([]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Handlers;
use Cart\Currency;
use Cart\Tax;
use Closure;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductsHandler
{
private Builder $queryBuilder;
private Currency $currency;
private Tax $tax;
private Settings $settings;
public function __construct(Builder $queryBuilder, Currency $currency, Tax $tax, Settings $settings)
{
$this->queryBuilder = $queryBuilder;
$this->currency = $currency;
$this->tax = $tax;
$this->settings = $settings;
}
public function handle(Request $request): JsonResponse
{
$languageId = 1;
$page = $request->get('page', 1);
$perPage = $request->get('perPage', 20);
$products = $this->queryBuilder->newQuery()
->select([
'products.product_id' => 'product_id',
'products.quantity' => 'product_quantity',
'product_description.name' => 'product_name',
'products.price' => 'price',
'products.image' => 'product_image',
'products.tax_class_id' => 'tax_class_id',
])
->from(db_table('product'), 'products')
->join(
db_table('product_description') . ' AS product_description',
function (JoinClause $join) use ($languageId) {
$join->on('products.product_id', '=', 'product_description.product_id')
->where('product_description.language_id', '=', $languageId);
}
)
->forPage($page, $perPage)
->get();
/** @var Closure $resize */
$resize = Application::getInstance()->get('image_resize');
return new JsonResponse([
'data' => array_map(function ($product) use ($resize) {
if ($product['product_image']) {
$image = $resize($product['product_image'], 500, 500);
} else {
$image = $resize('placeholder.png', 500, 500);
}
$price = $this->currency->format(
$this->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
),
$this->settings->get('oc_currency'),
);
return [
'id' => (int)$product['product_id'],
'product_quantity' => (int)$product['product_quantity'],
'name' => $product['product_name'],
'price' => $price,
'image' => $image,
];
}, $products),
]);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\ServiceProviders;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Router\Router;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->get(Router::class)->loadRoutesFromFile(__DIR__ . '/../routes.php');
}
}

View File

@@ -0,0 +1,42 @@
<?php
return [
'config_timezone' => 'UTC',
'lang' => 'en-gb',
'language_id' => 1,
'auth_user_id' => 0,
'base_url' => 'http://localhost',
'search_cache_seconds' => 60,
'chunk_size' => 100,
'retry_count' => 3,
'activity_log_retention_limit' => 5000,
'tables' => [
'selected_products' => 'bp_selected_products',
'simulation_results' => 'bp_simulation_results',
'activity_logs' => 'bp_activity_logs',
'cache' => 'bp_cache',
'task_progress' => 'bp_task_progress',
'task_steps' => 'bp_task_steps',
'search_results' => 'bp_search_results',
'settings' => 'bp_settings',
],
'db' => [
'host' => 'localhost',
'database' => 'not_set',
'username' => 'not_set',
'password' => 'not_set',
],
'logs' => [
'path' => 'not_set',
],
];

View File

@@ -0,0 +1,10 @@
<?php
use App\Handlers\HelloWorldHandler;
use App\Handlers\OrderCreateHandler;
use App\Handlers\ProductsHandler;
return [
'products' => [ProductsHandler::class, 'handle'],
'order_create' => [OrderCreateHandler::class, 'handle'],
];

View File

@@ -1,46 +0,0 @@
#!/bin/bash
set -e
VERSION="1.0.0"
MODULE_NAME="oc_telegram_shop"
CURRENT_DIR="$(dirname "$0")"
echo "Current dir: ${CURRENT_DIR}"
node -v
npm -v
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}🔨 Starting build process...${NC}"
echo -e "${BLUE}Build frontend...${NC}"
rm -rf /app/module/oc_telegram_shop/upload/admin/view/javascript
rm -rf /app/module/oc_telegram_shop/upload/admin/view/integration.js
cd /app/frontend
npm install
NODE_ENV=production npm run build
rm -rf /app/module/oc_telegram_shop/upload/admin/view/javascript/oc_telegram_shop/.vite
rm -rf /app/module/oc_telegram_shop/upload/admin/view/javascript/oc_telegram_shop/oc_telegram_shop.js.map
rm -rf /app/module/oc_telegram_shop/upload/admin/view/javascript/oc_telegram_shop/favicon.ico
echo -e "${BLUE}📦 Packaging into .ocmod.zip...${NC}"
cd /app/module/oc_telegram_shop
zip -rqq "${MODULE_NAME}.ocmod.zip" .
mv "${MODULE_NAME}.ocmod.zip" /build
FINAL_FILE="/build/${MODULE_NAME}.ocmod.zip"
if [ -f "$FINAL_FILE" ]; then
FILE_SIZE=$(du -h "$FINAL_FILE" | cut -f1)
echo -e "${GREEN}✅ Build completed successfully!${NC}"
echo -e "${GREEN}🎉 Created: $FINAL_FILE ($FILE_SIZE)${NC}"
else
echo "❌ Error: Final build file not found: $FINAL_FILE"
exit 1
fi
echo -e "${BLUE}🎯 Build artifact ready for installation!${NC}"

78
scripts/ci/build.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/bin/bash
set -e
MODULE_NAME="oc_telegram_shop"
CURRENT_DIR="$(dirname "$0")"
echo "Current dir: ${CURRENT_DIR}"
composer --version
node -v
npm -v
SRC_PATH="$1"
BUILD_PATH="${CURRENT_DIR}/build"
echo "🔨 Starting build process..."
echo " Source path: ${SRC_PATH}"
echo " Build path: ${BUILD_PATH}"
if [ -z "$BUILD_PATH" ]; then
echo "❌ Error: BUILD_PATH is required"
exit 1
fi
if [ -z "$SRC_PATH" ]; then
echo "❌ Error: SRC_PATH is required"
exit 1
fi
echo "Build SPA..."
cd "${SRC_PATH}/spa"
npm install
npm run build
cd - > /dev/null
echo "Install Composer dependencies."
cd "${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop"
composer install \
--prefer-dist \
--no-dev \
--optimize-autoloader \
--no-interaction \
--no-cache
cd - > /dev/null
echo "📜 Creating Phar archive..."
rm -rf \
"${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop/tests" \
"${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop/phpunit.xml" \
"${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop/psalm.xml" \
"${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop/psalm-classes.php" \
"${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop/psalm-stubs.php"
php -d phar.readonly=0 scripts/ci/create-phar.php "${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop" "${MODULE_NAME}.phar"
echo "📦 Packaging into .ocmod.zip..."
mkdir -p "${SRC_PATH}/module/oc_telegram_shop/upload/system/library/oc_telegram_shop"
mv "${MODULE_NAME}.phar" "${SRC_PATH}/module/oc_telegram_shop/upload/system/library/oc_telegram_shop"
rm -rf \
"${SRC_PATH}/module/oc_telegram_shop/upload/oc_telegram_shop"
cd "${SRC_PATH}/module/oc_telegram_shop/"
zip -rqq "${MODULE_NAME}.ocmod.zip" .
mv "${MODULE_NAME}.ocmod.zip" "${BUILD_PATH}"
FINAL_FILE="${BUILD_PATH}/${MODULE_NAME}.ocmod.zip"
if [ -f "$FINAL_FILE" ]; then
FILE_SIZE=$(du -h "$FINAL_FILE" | cut -f1)
echo -e "✅ Build completed successfully!"
echo -e "🎉 Created: $FINAL_FILE ($FILE_SIZE)"
else
echo "❌ Error: Final build file not found: $FINAL_FILE"
exit 1
fi
echo -e "🎯 Build artifact ready for installation!"

View File

@@ -0,0 +1,37 @@
<?php
$sourcePath = $argv[1] ?? null;
$pharFile = $argv[2] ?? null;
if (! $sourcePath) {
throw new InvalidArgumentException('Source path must be provided');
}
if (! $pharFile) {
throw new InvalidArgumentException('Phar file must be provided');
}
// Remove old file if exists
if (file_exists($pharFile)) {
unlink($pharFile);
}
// Create new Phar archive
$phar = new Phar($pharFile);
// Start buffering
$phar->startBuffering();
// Add files from source directory
$phar->buildFromDirectory($sourcePath);
// (Optional) Set stub file — main entry point
// $phar->setStub($phar->createDefaultStub('index.php'));
$phar->compressFiles(Phar::GZ);
// Stop buffering and write
$phar->stopBuffering();
echo "Phar created: $pharFile\n";

View File

@@ -1,6 +1,6 @@
<?php <?php
$module = 'oc_layout_pro'; $module = 'oc_telegram_shop';
$basePath = dirname(__DIR__) . '/'; $basePath = dirname(__DIR__) . '/';

30
spa/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"ofetch": "^1.4.1",
"vue": "^3.5.17" "vue": "^3.5.17"
}, },
"devDependencies": { "devDependencies": {
@@ -1321,6 +1322,12 @@
"url": "https://github.com/saadeghi/daisyui?sponsor=1" "url": "https://github.com/saadeghi/daisyui?sponsor=1"
} }
}, },
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -1767,6 +1774,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-fetch-native": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz",
"integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==",
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -1784,6 +1797,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ofetch": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz",
"integrity": "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==",
"license": "MIT",
"dependencies": {
"destr": "^2.0.3",
"node-fetch-native": "^1.6.4",
"ufo": "^1.5.4"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1933,6 +1957,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"ofetch": "^1.4.1",
"vue": "^3.5.17" "vue": "^3.5.17"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,63 +1,7 @@
<template> <template>
<div class="relative flex justify-center items-center p-3"> <ProductList/>
<h1 class="text-gray-950 text-lg font-bold truncate">Демо Телеграм-магазин</h1>
</div>
<div class="p-3">
<button
class="btn"
@click="drawerVisible = true"
>
Категории
<ChevronDownIcon class="w-5"/>
</button>
</div>
<div class="grid grid-cols-1 gap-4 p-3 sm:grid-cols-2 md:grid-cols-3">
<ProductCard/>
<ProductCard/>
<ProductCard/>
<ProductCard/>
<ProductCard/>
</div>
<BottomDrawer v-model="drawerVisible">
<ul class="list bg-base-100 rounded-box shadow-md">
<li class="p-4 pb-2 text-xs opacity-60 tracking-wide">Категории</li>
<li class="list-row">
<div><img class="size-10 rounded-box" src="https://img.daisyui.com/images/profile/demo/1@94.webp"/></div>
<div class="flex items-center">Dio Lupa</div>
</li>
<li class="list-row">
<div><img class="size-10 rounded-box" src="https://img.daisyui.com/images/profile/demo/1@94.webp"/></div>
<div class="flex items-center">Dio Lupa</div>
</li>
<li class="list-row">
<div><img class="size-10 rounded-box" src="https://img.daisyui.com/images/profile/demo/1@94.webp"/></div>
<div class="flex items-center">Dio Lupa</div>
</li>
<li class="list-row">
<div><img class="size-10 rounded-box" src="https://img.daisyui.com/images/profile/demo/1@94.webp"/></div>
<div class="flex items-center">Dio Lupa</div>
</li>
</ul>
</BottomDrawer>
</template> </template>
<script setup> <script setup>
import ProductCard from "./components/ProductCard.vue"; import ProductList from "./components/ProductList.vue";
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
import BottomDrawer from "./components/BottomDrawer.vue";
import {ref} from "vue";
const drawerVisible = ref(false);
</script> </script>
<style scoped>
</style>

View File

@@ -1,8 +1,10 @@
<template> <template>
<div class="bg-white rounded-lg shadow p-3"> <div class="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
<img src="https://placehold.co/300x300" alt="Футболка" class="w-full h-auto rounded-md mb-2" /> <a v-for="product in products" :key="product.id" :href="product.href" class="group">
<h2 class="font-semibold text-base">Базовая футболка</h2> <img :src="product.imageSrc" :alt="product.imageAlt" class="aspect-square w-full rounded-lg bg-gray-200 object-cover group-hover:opacity-75 xl:aspect-7/8" />
<p class="text-sm text-gray-500">RUB 6,000</p> <h3 class="mt-4 text-sm text-gray-700">{{ product.name }}</h3>
<p class="mt-1 text-lg font-medium text-gray-900">{{ product.price }}</p>
</a>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,28 @@
<template>
<div class="bg-white">
<div class="mx-auto max-w-2xl px-4 py-16 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8">
<h2 class="sr-only">Products</h2>
<div class="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
<a v-for="product in products" :key="product.id" class="group">
<img :src="product.image" :alt="product.name"
class="aspect-square w-full rounded-lg bg-gray-200 object-cover group-hover:opacity-75 xl:aspect-7/8"/>
<h3 class="mt-4 text-sm text-gray-700">{{ product.name }}</h3>
<p class="mt-1 text-lg font-medium text-gray-900">{{ product.price }}</p>
</a>
</div>
</div>
</div>
</template>
<script setup>
import {$fetch} from 'ofetch';
import {onMounted, ref} from "vue";
const products = ref([]);
onMounted(async () => {
const {data} = await $fetch('http://localhost:8000/index.php?route=tgshop/handle&api_action=products');
products.value = data;
});
</script>

View File

@@ -4,4 +4,12 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), vue()], plugins: [tailwindcss(), vue()],
});
base: '/telegram_shop_spa/',
build: {
outDir: '../module/oc_telegram_shop/upload/image/catalog/tgshopspa',
emptyOutDir: true,
sourcemap: true,
manifest: true,
},
});