feat: display product options

This commit is contained in:
Nikita Kiselev
2025-07-15 19:52:04 +03:00
parent 08d2453df9
commit f47bb46751
17 changed files with 678 additions and 53 deletions

View File

@@ -1,5 +1,7 @@
<?php
use App\Adapters\OcImageTool;
use App\Adapters\OcModelCatalogProductAdapter;
use App\ApplicationFactory;
use Cart\Currency;
use Cart\Tax;
@@ -20,9 +22,8 @@ class Controllerextensiontgshophandle extends Controller
{
$app = ApplicationFactory::create([
'oc_config_tax' => $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;
});
$app->bind(OcModelCatalogProductAdapter::class, function () {
$this->load->model('catalog/product');
return new OcModelCatalogProductAdapter($this->model_catalog_product);
});
$app->bind(OcImageTool::class, function () {
$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);
};
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();
}
}

View File

@@ -0,0 +1,295 @@
<?php
namespace Openguru\OpenCartFramework\Collection;
use ArrayAccess;
use InvalidArgumentException;
use ArrayIterator;
use IteratorAggregate;
use Countable;
use JsonSerializable;
/**
* A generic collection for managing objects of a specific type.
*
* @template T
* @implements IteratorAggregate<T>
*/
class Collection implements IteratorAggregate, Countable, ArrayAccess, JsonSerializable
{
/** @var array<T> */
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<T>
*/
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);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Adapters;
use Proxy;
use RuntimeException;
class OcImageTool
{
private Proxy $image;
public function __construct(Proxy $image)
{
$this->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;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Adapters;
use ModelCatalogProduct;
use Proxy;
class OcModelCatalogProductAdapter
{
/** @var Proxy|ModelCatalogProduct */
private Proxy $model;
public function __construct(Proxy $model)
{
$this->model = $model;
}
public function getProductOptions(int $productId): array
{
return $this->model->getProductOptions($productId);
}
}

View File

@@ -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'],

View File

@@ -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;
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="app-container">
<FullscreenViewport/>
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
<router-view />
</div>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<div v-for="option in options" :key="option.product_option_id" class="mt-3">
<OptionRadio v-if="option.type === 'radio'" :modelValue="option"/>
<OptionCheckbox v-else-if="option.type === 'checkbox'" :modelValue="option"/>
<OptionText v-else-if="option.type === 'text'" :modelValue="option"/>
<OptionTextarea v-else-if="option.type === 'textarea'" :modelValue="option"/>
<OptionSelect v-else-if="option.type === 'select'" :modelValue="option"/>
</div>
</template>
<script setup>
import OptionRadio from "./Types/OptionRadio.vue";
import OptionCheckbox from "./Types/OptionCheckbox.vue";
import OptionText from "./Types/OptionText.vue";
import OptionTextarea from "./Types/OptionTextarea.vue";
import OptionSelect from "./Types/OptionSelect.vue";
const options = defineModel();
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div>
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.values"
class="group relative flex items-center justify-center rounded-md border border-gray-300 bg-white p-2 has-checked:border-indigo-600 has-checked:bg-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:border-gray-400 has-disabled:bg-gray-200 has-disabled:opacity-25">
<input
type="checkbox"
:value="value.product_option_value_id"
:checked="value.selected"
@change="select(value)"
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/>
<span class="text-xs font-medium group-has-checked:text-white">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span>
</label>
</div>
</OptionTemplate>
</div>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(toggledValue) {
model.value.values.forEach(value => {
if (value === toggledValue) {
value.selected = !value.selected;
}
});
emit('update:modelValue', model.value);
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<div class="flex flex-wrap gap-2">
<label
v-for="value in model.values"
class="group relative flex items-center justify-center rounded-md border border-gray-300 bg-base-200 p-2 has-checked:border-indigo-600 has-checked:bg-primary has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:border-gray-400 has-disabled:bg-gray-200 has-disabled:opacity-25">
<input
type="radio"
:name="`option-${model.product_option_id}`"
:value="value.product_option_value_id"
:checked="value.selected"
@change="select(value)"
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/>
<span class="text-xs font-medium group-has-checked:text-white">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span>
</label>
</div>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function select(selectedValue) {
model.value.values.forEach(value => {
value.selected = (value === selectedValue);
});
emit('update:modelValue', model);
}
</script>

View File

@@ -0,0 +1,35 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<select
:name="`option-${model.product_option_id}`"
class="select"
@change="onChange"
>
<option
v-for="value in model.values"
:key="value.product_option_value_id"
:value="value.product_option_value_id"
:selected="value.selected"
>
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</option>
</select>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function onChange(event) {
const selectedId = Number(event.target.value);
model.value.values.forEach(value => {
value.selected = (value.product_option_value_id === selectedId);
});
emit('update:modelValue', model.value);
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<h3 class="text-sm mb-2">
{{ name }} <span v-if="required" class="text-red-500">*</span>
</h3>
<fieldset>
<slot></slot>
</fieldset>
</div>
</template>
<script setup>
defineProps({
name: {
type: String,
required: true,
},
required: {
type: Boolean,
default: false,
}
});
</script>

View File

@@ -0,0 +1,23 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<input
type="text"
class="input"
:placeholder="model.name"
:value="model.value"
@input="input(model, $event.target.value)"
/>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function input(model, newValue) {
model.value = newValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<OptionTemplate :name="model.name" :required="model.required">
<textarea
type="text"
class="textarea"
:placeholder="model.name"
v-text="model.value"
@input="input(model, $event.target.value)"
/>
</OptionTemplate>
</template>
<script setup>
import OptionTemplate from "./OptionTemplate.vue";
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
function input(model, newValue) {
model.value = newValue;
emit('update:modelValue', model);
}
</script>

View File

@@ -1,4 +1,4 @@
import {createRouter, createWebHistory} from 'vue-router';
import {createMemoryHistory, createRouter} from 'vue-router';
import Home from './views/Home.vue';
import Product from './views/Product.vue';
import CategoriesList from "./views/CategoriesList.vue";
@@ -12,7 +12,7 @@ const routes = [
];
export const router = createRouter({
history: createWebHistory('/image/catalog/tgshopspa/'),
history: createMemoryHistory('/image/catalog/tgshopspa/'),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {

View File

@@ -20,11 +20,14 @@
<h3 class="text-sm font-medium">{{ product.manufacturer }}</h3>
</div>
<!-- Options -->
<div class="mt-4 lg:row-span-3 lg:mt-0">
<p class="text-3xl tracking-tight">{{ product.price }}</p>
</div>
<div v-if="product.options && product.options.length" class="mt-4">
<ProductOptions v-model="product.options"/>
</div>
<div class="py-10 lg:col-span-2 lg:col-start-1 lg:border-r lg:border-gray-200 lg:pt-6 lg:pr-8 lg:pb-16">
<!-- Description and details -->
<div>
@@ -34,7 +37,6 @@
<p class="text-base" v-html="product.description"></p>
</div>
</div>
</div>
</div>
</div>
@@ -47,6 +49,7 @@ import {$fetch} from "ofetch";
import { useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
import {useHapticFeedback} from 'vue-tg';
import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
const hapticFeedback = useHapticFeedback();
const router = useRouter()