From f47bb46751fea79e43a96e2b63afde4cb7ef801b Mon Sep 17 00:00:00 2001 From: Nikita Kiselev Date: Tue, 15 Jul 2025 19:52:04 +0300 Subject: [PATCH] feat: display product options --- .../controller/extension/tgshop/handle.php | 30 +- .../framework/Collection/Collection.php | 295 ++++++++++++++++++ .../framework/Support/helpers.php | 2 +- .../src/Adapters/OcImageTool.php | 30 ++ .../Adapters/OcModelCatalogProductAdapter.php | 22 ++ .../src/Handlers/CategoriesHandler.php | 18 +- .../src/Handlers/ProductsHandler.php | 116 +++++-- spa/src/App.vue | 2 +- .../ProductOptions/ProductOptions.vue | 20 ++ .../ProductOptions/Types/OptionCheckbox.vue | 41 +++ .../ProductOptions/Types/OptionRadio.vue | 38 +++ .../ProductOptions/Types/OptionSelect.vue | 35 +++ .../ProductOptions/Types/OptionTemplate.vue | 25 ++ .../ProductOptions/Types/OptionText.vue | 23 ++ .../ProductOptions/Types/OptionTextarea.vue | 23 ++ spa/src/router.js | 4 +- spa/src/views/Product.vue | 7 +- 17 files changed, 678 insertions(+), 53 deletions(-) create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/framework/Collection/Collection.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcImageTool.php create mode 100755 module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcModelCatalogProductAdapter.php create mode 100644 spa/src/components/ProductOptions/ProductOptions.vue create mode 100644 spa/src/components/ProductOptions/Types/OptionCheckbox.vue create mode 100644 spa/src/components/ProductOptions/Types/OptionRadio.vue create mode 100644 spa/src/components/ProductOptions/Types/OptionSelect.vue create mode 100644 spa/src/components/ProductOptions/Types/OptionTemplate.vue create mode 100644 spa/src/components/ProductOptions/Types/OptionText.vue create mode 100644 spa/src/components/ProductOptions/Types/OptionTextarea.vue diff --git a/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php b/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php index 51d087e..006fc29 100755 --- a/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php +++ b/module/oc_telegram_shop/upload/catalog/controller/extension/tgshop/handle.php @@ -1,5 +1,7 @@ $this->config->get('config_tax'), - 'oc_currency' => $this->session->data['currency'], + 'oc_default_currency' => $this->config->get('config_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, @@ -42,18 +43,21 @@ class Controllerextensiontgshophandle extends Controller return $this->url; }); - $app->bind(Currency::class, function () { return $this->currency; }); - $app->bind(Tax::class, function () { return $this->tax; }); + $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); - } + $app->bind(OcModelCatalogProductAdapter::class, function () { + $this->load->model('catalog/product'); + return new OcModelCatalogProductAdapter($this->model_catalog_product); + }); - return $this->model_tool_image->resize('no_image.png', $width, $height); - }; + $app->bind(OcImageTool::class, function () { + $this->load->model('tool/image'); + return new OcImageTool($this->model_tool_image); }); $this->load->model('checkout/order'); @@ -62,6 +66,8 @@ class Controllerextensiontgshophandle extends Controller return $this->model_checkout_order; }); + $this->session->data['language'] = $this->config->get('config_language'); + $app->bootAndHandleRequest(); } } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Collection/Collection.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Collection/Collection.php new file mode 100755 index 0000000..26e35da --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Collection/Collection.php @@ -0,0 +1,295 @@ + + */ +class Collection implements IteratorAggregate, Countable, ArrayAccess, JsonSerializable +{ + /** @var array */ + protected array $items; + + /** @var string|null */ + protected ?string $expectedClass; + + /** + * Constructor. + * + * @param array $items Initial items for the collection. + * @param string|null $expectedClass The expected class type for the items. + * @throws InvalidArgumentException + */ + public function __construct(array $items = [], ?string $expectedClass = null) + { + $this->expectedClass = $expectedClass; + + foreach ($items as $item) { + $this->validateItem($item); + } + + $this->items = $items; + } + + /** + * Add an item to the collection. + * + * @param mixed $item + * @return self + * @throws InvalidArgumentException + */ + public function add($item): self + { + $this->validateItem($item); + $this->items[] = $item; + return $this; + } + + /** + * Get all items. + * + * @return array + */ + public function all(): array + { + return $this->items; + } + + /** + * Filter items based on a callback. + * + * @param callable $callback + * @return self + */ + public function filter(callable $callback): self + { + return new self(array_values(array_filter($this->items, $callback)), $this->expectedClass); + } + + /** + * Apply a callback to each item and return a new collection. + * + * @param callable $callback + * @return array + */ + public function map(callable $callback): array + { + return array_map($callback, $this->items); + } + + /** + * Reduce the collection to a single value. + * + * @param callable $callback + * @param mixed $initial + * @return mixed + */ + public function reduce(callable $callback, $initial) + { + return array_reduce($this->items, $callback, $initial); + } + + /** + * Find an item by a specific property and value. + * + * @param string $property + * @param mixed $value + * @return T|null + */ + public function findByProperty(string $property, $value) + { + foreach ($this->items as $item) { + if (is_object($item) && property_exists($item, $property) && $item->$property === $value) { + return $item; + } + } + + return null; + } + + /** + * Check if an item exists with a specific property and value. + * + * @param string $property + * @param mixed $value + * @return bool + */ + public function hasValue(string $property, $value): bool + { + return $this->findByProperty($property, $value) !== null; + } + + /** + * Validate an item type. + * + * @param mixed $item + * @throws InvalidArgumentException + */ + private function validateItem($item): void + { + if ($this->expectedClass !== null && !($item instanceof $this->expectedClass)) { + throw new InvalidArgumentException( + sprintf( + 'Item must be an instance of %s, %s given.', + $this->expectedClass, + is_object($item) ? get_class($item) : gettype($item) + ) + ); + } + } + + /** + * Get an iterator for the collection. + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } + + /** + * Count the number of items in the collection. + * + * @return int + */ + public function count(): int + { + return count($this->items); + } + + /** + * Check if an offset exists. + * + * @param mixed $offset + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->items[$offset]); + } + + /** + * Get an item at a specific offset. + * + * @param mixed $offset + * @return mixed + */ + public function offsetGet($offset) + { + return $this->items[$offset] ?? null; + } + + /** + * Set an item at a specific offset. + * + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + $this->validateItem($value); + + if ($offset === null) { + $this->items[] = $value; + } else { + $this->items[$offset] = $value; + } + } + + /** + * Unset an item at a specific offset. + * + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + unset($this->items[$offset]); + } + + public function getValueByProperty(string $property, $propertyValue) + { + $item = $this->findByProperty($property, $propertyValue); + + return $item ? $item->getValue() : null; + } + + /** + * @return T|null + */ + public function first() + { + return $this->items[0] ?? null; + } + + public function getFirstItemValue(string $field, $default = null) + { + if ($this->first() === null) { + return $default; + } + + return $this->first()->{$field}; + } + + public function groupBy(callable $callback): array + { + $grouped = []; + + foreach ($this->items as $item) { + $key = $callback($item); + $grouped[$key][] = $item; + } + + return $grouped; + } + + public function keyByField(callable $callback): array + { + $items = []; + + foreach ($this->items as $item) { + $key = $callback($item); + $items[$key] = $item; + } + + return $items; + } + + public function toArray(): array + { + return $this->all(); + } + + public function pluck(string $field): array + { + $result = []; + + foreach ($this->items as $item) { + if (is_object($item)) { + $result[] = $item->$field; + } else { + $result[] = $item[$field]; + } + } + + return $result; + } + + public function jsonSerialize(): array + { + return $this->all(); + } + + public function append(array $items): Collection + { + return new Collection(array_merge($this->items, $items), $this->expectedClass); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Support/helpers.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Support/helpers.php index 0d01cf1..180f2ae 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Support/helpers.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Support/helpers.php @@ -51,7 +51,7 @@ if (!function_exists('config')) { } } -if (! function_exists('dd')) { +if (!function_exists('dd')) { function dd(): void { Utils::dd(func_get_args()); diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcImageTool.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcImageTool.php new file mode 100755 index 0000000..38234c8 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcImageTool.php @@ -0,0 +1,30 @@ +image = $image; + } + + public function resize(string $path, int $width, int $height, ?string $default = null): ?string + { + if ($path && is_file(DIR_IMAGE . $path)) { + $image = $path; + return $this->image->resize($image, $width, $height); + } + + if ($default && is_file(DIR_IMAGE . $default)) { + return $this->image->resize($default, $width, $height); + } + + return null; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcModelCatalogProductAdapter.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcModelCatalogProductAdapter.php new file mode 100755 index 0000000..8a6115e --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Adapters/OcModelCatalogProductAdapter.php @@ -0,0 +1,22 @@ +model = $model; + } + + public function getProductOptions(int $productId): array + { + return $this->model->getProductOptions($productId); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CategoriesHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CategoriesHandler.php index 2b80edd..0677bd6 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CategoriesHandler.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CategoriesHandler.php @@ -2,8 +2,7 @@ namespace App\Handlers; -use Closure; -use Openguru\OpenCartFramework\Application; +use App\Adapters\OcImageTool; use Openguru\OpenCartFramework\Http\JsonResponse; use Openguru\OpenCartFramework\Http\Request; use Openguru\OpenCartFramework\QueryBuilder\Builder; @@ -12,10 +11,12 @@ use Openguru\OpenCartFramework\QueryBuilder\JoinClause; class CategoriesHandler { private Builder $queryBuilder; + private OcImageTool $imageTool; - public function __construct(Builder $queryBuilder) + public function __construct(Builder $queryBuilder, OcImageTool $ocImageTool) { $this->queryBuilder = $queryBuilder; + $this->ocImageTool = $ocImageTool; } public function index(Request $request): JsonResponse @@ -47,16 +48,9 @@ class CategoriesHandler ->limit($perPage) ->get(); - /** @var Closure $resize */ - $resize = Application::getInstance()->get('image_resize'); - return new JsonResponse([ - 'data' => array_map(static function ($category) use ($resize, $imageWidth, $imageHeight) { - if ($category['image']) { - $image = $resize($category['image'], $imageWidth, $imageHeight); - } else { - $image = $resize('no_image.png', $imageWidth, $imageHeight); - } + 'data' => array_map(function ($category) use ($imageWidth, $imageHeight) { + $image = $this->ocImageTool->resize($category['image'], $imageWidth, $imageHeight, 'no_image.png'); return [ 'id' => (int)$category['id'], diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/ProductsHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/ProductsHandler.php index 993df51..262d1b6 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/ProductsHandler.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/ProductsHandler.php @@ -2,13 +2,14 @@ namespace App\Handlers; +use App\Adapters\OcImageTool; +use App\Adapters\OcModelCatalogProductAdapter; 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\Http\Response; use Openguru\OpenCartFramework\QueryBuilder\Builder; use Openguru\OpenCartFramework\QueryBuilder\JoinClause; use Openguru\OpenCartFramework\Support\Arr; @@ -19,13 +20,23 @@ class ProductsHandler private Currency $currency; private Tax $tax; private Settings $settings; + private OcModelCatalogProductAdapter $ocModelCatalogProduct; + private OcImageTool $ocImageTool; - public function __construct(Builder $queryBuilder, Currency $currency, Tax $tax, Settings $settings) - { + public function __construct( + Builder $queryBuilder, + Currency $currency, + Tax $tax, + Settings $settings, + OcModelCatalogProductAdapter $ocModelCatalogProduct, + OcImageTool $ocImageTool + ) { $this->queryBuilder = $queryBuilder; $this->currency = $currency; $this->tax = $tax; $this->settings = $settings; + $this->ocModelCatalogProduct = $ocModelCatalogProduct; + $this->ocImageTool = $ocImageTool; } public function handle(Request $request): JsonResponse @@ -93,26 +104,24 @@ class ProductsHandler ->get(); } - - /** @var Closure $resize */ - $resize = Application::getInstance()->get('image_resize'); - $productsImagesMap = []; foreach ($productsImages as $item) { $productsImagesMap[$item['product_id']][] = [ - 'url' => $resize($item['image'], $imageWidth, $imageHeight), + 'url' => $this->ocImageTool->resize($item['image'], $imageWidth, $imageHeight, 'placeholder.png'), 'alt' => 'Product Image', ]; } return new JsonResponse([ - 'data' => array_map(function ($product) use ($resize, $productsImagesMap, $imageWidth, $imageHeight) { + 'data' => array_map(function ($product) use ($productsImagesMap, $imageWidth, $imageHeight) { $allImages = []; - if ($product['product_image']) { - $image = $resize($product['product_image'], $imageWidth, $imageHeight); - } else { - $image = $resize('placeholder.png', $imageWidth, $imageHeight); - } + + $image = $this->ocImageTool->resize( + $product['product_image'], + $imageWidth, + $imageHeight, + 'placeholder.png' + ); $allImages[] = [ 'url' => $image, @@ -125,7 +134,7 @@ class ProductsHandler $product['tax_class_id'], $this->settings->get('oc_config_tax'), ), - $this->settings->get('oc_currency'), + $this->settings->get('oc_default_currency'), ); if (!empty($productsImagesMap[$product['product_id']])) { @@ -173,7 +182,7 @@ class ProductsHandler ->where('product_description.language_id', '=', $languageId); } ) - ->join( + ->leftJoin( db_table('manufacturer') . ' AS manufacturer', function (JoinClause $join) use ($languageId) { $join->on('products.manufacturer_id', '=', 'manufacturer.manufacturer_id'); @@ -183,6 +192,10 @@ class ProductsHandler ->limit(1) ->firstOrNull(); + if (!$product) { + return new JsonResponse([], Response::HTTP_NOT_FOUND); + } + $productsImages = $this->queryBuilder->newQuery() ->select([ 'products_images.product_id' => 'product_id', @@ -193,17 +206,20 @@ class ProductsHandler ->where('products_images.product_id', '=', $productId) ->get(); - /** @var Closure $resize */ - $resize = Application::getInstance()->get('image_resize'); - $images = []; $images[] = [ - 'url' => $resize($product['product_image'], $imageWidth, $imageHeight), + 'url' => $this->ocImageTool->resize( + $product['product_image'], + $imageWidth, + $imageHeight, + 'placeholder.png' + ), 'alt' => $product['product_name'], ]; + foreach ($productsImages as $item) { $images[] = [ - 'url' => $resize($item['image'], $imageWidth, $imageHeight), + 'url' => $this->ocImageTool->resize($item['image'], $imageWidth, $imageHeight, 'placeholder.png'), 'alt' => $product['product_name'], ]; } @@ -214,7 +230,7 @@ class ProductsHandler $product['tax_class_id'], $this->settings->get('oc_config_tax'), ), - $this->settings->get('oc_currency'), + $this->settings->get('oc_default_currency'), ); $data = [ @@ -224,10 +240,64 @@ class ProductsHandler 'manufacturer' => $product['product_manufacturer'], 'price' => $price, 'images' => $images, + 'options' => $this->loadProductOptions($product), ]; return new JsonResponse([ 'data' => $data, ]); } + + private function loadProductOptions($product): array + { + $result = []; + $productId = $product['product_id']; + $taxClassId = $product['tax_class_id']; + + $options = $this->ocModelCatalogProduct->getProductOptions($productId); + $ocConfigTax = $this->settings->get('oc_config_tax'); + $ocDefaultCurrency = $this->settings->get('oc_default_currency'); + + foreach ($options as $option) { + $product_option_value_data = []; + + foreach ($option['product_option_value'] as $option_value) { + if (!$option_value['subtract'] || ($option_value['quantity'] > 0)) { + if ((float)$option_value['price']) { + $priceWithTax = $this->tax->calculate( + $option_value['price'], + $taxClassId, + $ocConfigTax ? 'Р' : false, + ); + + $price = $this->currency->format($priceWithTax, $ocDefaultCurrency); + } else { + $price = false; + } + + $product_option_value_data[] = [ + 'product_option_value_id' => (int)$option_value['product_option_value_id'], + 'option_value_id' => (int)$option_value['option_value_id'], + 'name' => $option_value['name'], + 'image' => $this->ocImageTool->resize($option_value['image'], 50, 50), + 'price' => $price, + 'price_prefix' => $option_value['price_prefix'], + 'selected' => false, + ]; + } + } + + $result[] = [ + 'product_option_id' => (int)$option['product_option_id'], + 'values' => $product_option_value_data, + 'option_id' => (int)$option['option_id'], + 'name' => $option['name'], + 'type' => $option['type'], + 'value' => $option['value'], + 'required' => filter_var($option['required'], FILTER_VALIDATE_BOOLEAN), + ]; + } + + return $result; + } } diff --git a/spa/src/App.vue b/spa/src/App.vue index 689f1b9..b1a2d15 100644 --- a/spa/src/App.vue +++ b/spa/src/App.vue @@ -1,6 +1,6 @@ diff --git a/spa/src/components/ProductOptions/ProductOptions.vue b/spa/src/components/ProductOptions/ProductOptions.vue new file mode 100644 index 0000000..50ade1b --- /dev/null +++ b/spa/src/components/ProductOptions/ProductOptions.vue @@ -0,0 +1,20 @@ + + + diff --git a/spa/src/components/ProductOptions/Types/OptionCheckbox.vue b/spa/src/components/ProductOptions/Types/OptionCheckbox.vue new file mode 100644 index 0000000..cbcff0d --- /dev/null +++ b/spa/src/components/ProductOptions/Types/OptionCheckbox.vue @@ -0,0 +1,41 @@ + + + diff --git a/spa/src/components/ProductOptions/Types/OptionRadio.vue b/spa/src/components/ProductOptions/Types/OptionRadio.vue new file mode 100644 index 0000000..b2cd9e6 --- /dev/null +++ b/spa/src/components/ProductOptions/Types/OptionRadio.vue @@ -0,0 +1,38 @@ + + + diff --git a/spa/src/components/ProductOptions/Types/OptionSelect.vue b/spa/src/components/ProductOptions/Types/OptionSelect.vue new file mode 100644 index 0000000..d26302b --- /dev/null +++ b/spa/src/components/ProductOptions/Types/OptionSelect.vue @@ -0,0 +1,35 @@ + + + diff --git a/spa/src/components/ProductOptions/Types/OptionTemplate.vue b/spa/src/components/ProductOptions/Types/OptionTemplate.vue new file mode 100644 index 0000000..e0b4048 --- /dev/null +++ b/spa/src/components/ProductOptions/Types/OptionTemplate.vue @@ -0,0 +1,25 @@ + + + diff --git a/spa/src/components/ProductOptions/Types/OptionText.vue b/spa/src/components/ProductOptions/Types/OptionText.vue new file mode 100644 index 0000000..e257ddc --- /dev/null +++ b/spa/src/components/ProductOptions/Types/OptionText.vue @@ -0,0 +1,23 @@ + + + diff --git a/spa/src/components/ProductOptions/Types/OptionTextarea.vue b/spa/src/components/ProductOptions/Types/OptionTextarea.vue new file mode 100644 index 0000000..5969051 --- /dev/null +++ b/spa/src/components/ProductOptions/Types/OptionTextarea.vue @@ -0,0 +1,23 @@ +