diff --git a/assets/components/minishop3/js/web/core/ApiClient.js b/assets/components/minishop3/js/web/core/ApiClient.js index 7ce58c76..08538928 100644 --- a/assets/components/minishop3/js/web/core/ApiClient.js +++ b/assets/components/minishop3/js/web/core/ApiClient.js @@ -34,8 +34,16 @@ class ApiClient { */ buildUrl (endpoint) { const url = new URL(this.baseUrl, window.location.origin) - url.searchParams.set('route', endpoint) + const qPos = endpoint.indexOf('?') + const path = qPos === -1 ? endpoint : endpoint.slice(0, qPos) + const query = qPos === -1 ? '' : endpoint.slice(qPos + 1) + url.searchParams.set('route', path) url.searchParams.set('ctx', this.ctx) + if (query !== '') { + new URLSearchParams(query).forEach((value, key) => { + url.searchParams.set(key, value) + }) + } return url } diff --git a/assets/components/minishop3/js/web/core/CartAPI.js b/assets/components/minishop3/js/web/core/CartAPI.js index c0988295..a5a60cfa 100644 --- a/assets/components/minishop3/js/web/core/CartAPI.js +++ b/assets/components/minishop3/js/web/core/CartAPI.js @@ -9,8 +9,9 @@ * success: true/false, * message: "Message", * data: { - * cart: [], // Product array - * status: {}, // Cart totals (total_cost, total_count, etc.) + * cart: {}, // Legacy map keyed by product_key (empty object, not []) + * items: [], // Always an array of line items (preferred for Nuxt) + * status: {}, // Cart totals (total_cost, total_count, total_weight, total_discount, total_positions) * render: {} // HTML blocks for rendering (if requested) * } * } @@ -35,20 +36,19 @@ class CartAPI { * * GET /api/v1/cart/get * - * @param {Object} params - Additional parameters - * @param {Object} params.render - Render configuration (selectors for HTML update) - * @returns {Promise} - { success, message, data: { cart, status, render } } + * @param {Object} [params] - Query flags + * @param {boolean|number|string} [params.include_thumbs] - Opt-in item.thumb from product data + * @returns {Promise} - { success, message, data: { cart, items, status, render } } * * @example * const response = await cart.get() - * console.log(response.data.cart) + * console.log(response.data.items) * console.log(response.data.status.total_cost) */ async get (params = {}) { - const endpoint = '/api/v1/cart/get' - - if (params.render) { - // TODO: add render parameter support in backend + let endpoint = '/api/v1/cart/get' + if (params.include_thumbs) { + endpoint += '?include_thumbs=1' } return this.api.get(endpoint) diff --git a/core/components/minishop3/src/Controllers/Api/Web/CartController.php b/core/components/minishop3/src/Controllers/Api/Web/CartController.php index ebffe0f4..be9ba7a2 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/CartController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/CartController.php @@ -7,6 +7,8 @@ use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; use MiniShop3\Services\Api\WebApiContextResolver; +use MiniShop3\Services\Cart\CartResponseNormalizer; +use MiniShop3\Services\Catalog\CatalogQuery; use MODX\Revolution\modX; /** @@ -171,6 +173,10 @@ public function remove(array $params = []): Response * Get cart * GET /api/v1/cart/get * + * Query: include_thumbs (0|1, default 0). + * Response data: items (always array), cart (legacy map, empty object), status totals. + * Cart status is merchandise only. Delivery/payment/final: GET /api/v1/order/cost. + * * @param array $params URL parameters * @return Response */ @@ -264,6 +270,15 @@ protected function transformResponse(array $result): Response $input = $this->getRequestData(); $renderTokens = $input['render'] ?? null; + if (!empty($result['success']) && is_array($result['data'] ?? null)) { + /** @var CartResponseNormalizer $normalizer */ + $normalizer = $this->modx->services->get('ms3_cart_response_normalizer'); + $result['data'] = $normalizer->normalize( + $result['data'], + CatalogQuery::toBool($input['include_thumbs'] ?? false) + ); + } + if (!empty($renderTokens) && !empty($result['success'])) { $customerToken = $_REQUEST['ms3_token'] ?? ''; diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 2e1e2564..a2e7d1d0 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -293,6 +293,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Cart\CartItemManager::class, 'interface' => null, ], + 'ms3_cart_response_normalizer' => [ + 'class' => \MiniShop3\Services\Cart\CartResponseNormalizer::class, + 'interface' => null, + ], 'ms3_cart_mutation_handler' => [ 'class' => \MiniShop3\Services\Cart\CartMutationHandler::class, 'interface' => null, diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 522b9ac8..6d676a33 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -98,6 +98,7 @@ public static function map(): array 'ms3_order_log' => $modxAndMs3(), 'ms3_manager_order_cost_recalculator' => $modxAndMs3(), 'ms3_cart_item_manager' => $modxAndMs3(), + 'ms3_cart_response_normalizer' => $modxOnly(), 'ms3_customer_address_manager' => $modxAndMs3(), 'ms3_customer_field_manager' => $modxAndMs3(), diff --git a/core/components/minishop3/src/Services/Cart/CartResponseNormalizer.php b/core/components/minishop3/src/Services/Cart/CartResponseNormalizer.php new file mode 100644 index 00000000..96b82444 --- /dev/null +++ b/core/components/minishop3/src/Services/Cart/CartResponseNormalizer.php @@ -0,0 +1,224 @@ + $data Domain cart payload (cart + status) + * @return array + */ + public function normalize(array $data, bool $includeThumbs = false): array + { + $cartMap = self::legacyCartMap($data['cart'] ?? []); + $items = self::projectItems($cartMap); + if ($includeThumbs) { + $items = $this->withThumbs($items); + } + + $data['cart'] = $cartMap === [] ? new \stdClass() : $cartMap; + $data['items'] = $items; + $data['status'] = self::projectStatus($data['status'] ?? []); + + return $data; + } + + /** + * @param mixed $cart + * @return array> + */ + private static function legacyCartMap(mixed $cart): array + { + if (!is_array($cart) || $cart === []) { + return []; + } + + $out = []; + if (array_is_list($cart)) { + foreach ($cart as $row) { + if (!is_array($row)) { + continue; + } + $key = (string) ($row['product_key'] ?? ''); + if ($key !== '') { + $out[$key] = $row; + } + } + + return $out; + } + + foreach ($cart as $key => $row) { + if (is_array($row)) { + $out[(string) $key] = $row; + } + } + + return $out; + } + + /** + * @param array> $cartMap + * @return list> + */ + private static function projectItems(array $cartMap): array + { + $keys = array_keys($cartMap); + usort( + $keys, + static function (string|int $a, string|int $b) use ($cartMap): int { + $idA = (int) ($cartMap[$a]['id'] ?? 0); + $idB = (int) ($cartMap[$b]['id'] ?? 0); + + return $idA <=> $idB ?: strcmp((string) $a, (string) $b); + } + ); + + $items = []; + foreach ($keys as $key) { + $items[] = self::projectItem((string) $key, $cartMap[$key]); + } + + return $items; + } + + /** + * @param array $raw + * @return array + */ + private static function projectItem(string $productKey, array $raw): array + { + $props = self::assoc($raw['properties'] ?? []); + $options = self::assoc($raw['options'] ?? []); + $count = (int) ($raw['count'] ?? 0); + $discountPrice = $props['discount_price'] ?? 0; + + return [ + 'product_key' => $productKey !== '' ? $productKey : (string) ($raw['product_key'] ?? ''), + 'product_id' => (int) ($raw['product_id'] ?? 0), + 'name' => (string) ($raw['name'] ?? ''), + 'count' => $count, + 'price' => self::money($raw['price'] ?? 0), + 'cost' => self::money($raw['cost'] ?? 0), + 'weight' => self::weight($raw['weight'] ?? 0), + 'options' => $options === [] ? new \stdClass() : $options, + 'old_price' => self::money($props['old_price'] ?? 0), + 'discount_price' => self::money($discountPrice), + 'discount_cost' => self::money($props['discount_cost'] ?? $discountPrice * $count), + ]; + } + + /** + * @param array $status + * @return array{ + * total_positions: int, + * total_count: int, + * total_cost: float, + * total_weight: float, + * total_discount: float + * } + */ + private static function projectStatus(array $status): array + { + return [ + 'total_positions' => (int) ($status['total_positions'] ?? 0), + 'total_count' => (int) ($status['total_count'] ?? 0), + 'total_cost' => self::money($status['total_cost'] ?? 0), + 'total_weight' => self::weight($status['total_weight'] ?? 0), + 'total_discount' => self::money($status['total_discount'] ?? 0), + ]; + } + + /** + * @param list> $items + * @return list> + */ + private function withThumbs(array $items): array + { + $ids = []; + foreach ($items as $item) { + $id = (int) ($item['product_id'] ?? 0); + if ($id > 0) { + $ids[$id] = $id; + } + } + + $urls = $this->lookupThumbs(array_values($ids)); + foreach ($items as $i => $item) { + $productId = (int) ($item['product_id'] ?? 0); + $url = trim($urls[$productId] ?? ''); + $items[$i]['thumb'] = $url !== '' ? $url : null; + } + + return $items; + } + + /** + * @param list $productIds + * @return array + */ + protected function lookupThumbs(array $productIds): array + { + if ($productIds === []) { + return []; + } + + $c = $this->modx->newQuery(msProductData::class); + $c->where(['id:IN' => $productIds]); + $c->select('id, thumb'); + + $out = []; + /** @var msProductData $row */ + foreach ($this->modx->getCollection(msProductData::class, $c) ?: [] as $row) { + $id = (int) $row->get('id'); + $url = trim((string) $row->get('thumb')); + if ($id > 0 && $url !== '') { + $out[$id] = $url; + } + } + + return $out; + } + + private static function money(mixed $value): float + { + return round((float) $value, self::MONEY_SCALE); + } + + private static function weight(mixed $value): float + { + return round((float) $value, self::WEIGHT_SCALE); + } + + /** + * @return array + */ + private static function assoc(mixed $value): array + { + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : []; + } + + return is_array($value) ? $value : []; + } +} diff --git a/core/components/minishop3/tests/CartResponseContractTest.php b/core/components/minishop3/tests/CartResponseContractTest.php new file mode 100644 index 00000000..54f573ee --- /dev/null +++ b/core/components/minishop3/tests/CartResponseContractTest.php @@ -0,0 +1,101 @@ +transformResponse($result);') < 6) { + $fail('every cart mutation and get must use transformResponse'); +} + +if (!str_contains($controller, 'ms3_cart_response_normalizer')) { + $fail('CartController must resolve ms3_cart_response_normalizer'); +} + +if (!str_contains($controller, 'include_thumbs')) { + $fail('CartController must document include_thumbs'); +} + +if (!str_contains($controller, 'order/cost')) { + $fail('CartController must document order/cost boundary'); +} + +if (str_contains($controller, 'OrderCostCalculator')) { + $fail('CartController must not call OrderCostCalculator'); +} + +if (!str_contains($normalizer, 'new \\stdClass()')) { + $fail('empty cart must encode as JSON object'); +} + +if (!str_contains($normalizer, "'items'")) { + $fail('normalizer must project items array'); +} + +if (!str_contains($controller, 'CatalogQuery::toBool')) { + $fail('CartController must parse include_thumbs at the HTTP boundary'); +} + +if (!str_contains($normalizer, 'bool $includeThumbs')) { + $fail('normalizer must take includeThumbs as bool, not a request bag'); +} + +if (str_contains($normalizer, 'OrderCostCalculator')) { + $fail('CartResponseNormalizer must not call OrderCostCalculator'); +} + +if (!str_contains($registry, 'ms3_cart_response_normalizer')) { + $fail('ServiceRegistry missing ms3_cart_response_normalizer'); +} + +if (!str_contains($factories, "'ms3_cart_response_normalizer'")) { + $fail('ServiceRegistryFactories missing ms3_cart_response_normalizer'); +} + +if (!str_contains($cartApi, 'items: []')) { + $fail('CartAPI must document items array'); +} + +if (!str_contains($cartApi, 'include_thumbs=1')) { + $fail('CartAPI.get must send include_thumbs'); +} + +if (!str_contains($apiClient, 'new URLSearchParams(query)')) { + $fail('ApiClient.buildUrl must copy endpoint query next to route, not inside route'); +} + +fwrite(STDOUT, "OK: Cart response contract checks passed\n"); +exit(0); diff --git a/core/components/minishop3/tests/Integration/WebApi/Support/JourneyWebApiModx.php b/core/components/minishop3/tests/Integration/WebApi/Support/JourneyWebApiModx.php index 84080568..a4fc0dee 100644 --- a/core/components/minishop3/tests/Integration/WebApi/Support/JourneyWebApiModx.php +++ b/core/components/minishop3/tests/Integration/WebApi/Support/JourneyWebApiModx.php @@ -5,11 +5,12 @@ namespace MiniShop3\Tests\Integration\WebApi\Support; use MiniShop3\Model\msCustomer; +use MiniShop3\Services\Cart\CartResponseNormalizer; use MiniShop3\Tests\Stubs\ProcessorResponseStub; use MODX\Revolution\WebApiModxStub; /** - * WebApiModxStub extended with journey DI (ms3, catalog, customer orders). + * WebApiModxStub extended with journey DI (ms3, catalog, cart projection, customer orders). */ final class JourneyWebApiModx extends WebApiModxStub { @@ -21,6 +22,8 @@ final class JourneyWebApiModx extends WebApiModxStub public JourneyCustomerOrderService $customerOrders; + public CartResponseNormalizer $cartNormalizer; + /** @var array */ private array $options = []; @@ -42,6 +45,7 @@ public function get(string $field): string $this->tokenService = $this->journeyTokens; $this->catalog = new JourneyProductCatalog($this); $this->customerOrders = new JourneyCustomerOrderService($this); + $this->cartNormalizer = new CartResponseNormalizer($this); $rlPath = sys_get_temp_dir() . '/ms3-webapi-rl-' . getmypid(); if (!is_dir($rlPath)) { @@ -72,6 +76,7 @@ public function has(string $key): bool 'ms3_token_service', 'ms3_product_catalog', 'ms3_customer_order', + 'ms3_cart_response_normalizer', ], true); } @@ -82,6 +87,7 @@ public function get(string $key): mixed 'ms3_token_service' => $this->modx->tokenService, 'ms3_product_catalog' => $this->modx->catalog, 'ms3_customer_order' => $this->modx->customerOrders, + 'ms3_cart_response_normalizer' => $this->modx->cartNormalizer, default => null, }; } diff --git a/core/components/minishop3/tests/Unit/Services/Cart/CartResponseNormalizerTest.php b/core/components/minishop3/tests/Unit/Services/Cart/CartResponseNormalizerTest.php new file mode 100644 index 00000000..7e489f45 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Cart/CartResponseNormalizerTest.php @@ -0,0 +1,198 @@ +normalizer = new CartResponseNormalizer(new modX()); + } + + public function testEmptyCartIsObjectAndItemsAreArray(): void + { + $out = $this->normalizer->normalize([ + 'cart' => [], + 'status' => [], + ]); + + self::assertInstanceOf(\stdClass::class, $out['cart']); + self::assertSame('{}', json_encode($out['cart'])); + self::assertSame([], $out['items']); + self::assertSame('[]', json_encode($out['items'])); + self::assertSame(0, $out['status']['total_positions']); + self::assertSame(0, $out['status']['total_count']); + self::assertSame(0.0, $out['status']['total_cost']); + self::assertSame(0.0, $out['status']['total_weight']); + self::assertSame(0.0, $out['status']['total_discount']); + } + + public function testNonEmptyItemsMatchPositionsAndHideInternalFields(): void + { + $out = $this->normalizer->normalize([ + 'cart' => [ + 'ms-b' => [ + 'id' => 20, + 'order_id' => 99, + 'hash' => 'secret', + 'product_id' => 2, + 'name' => 'Later', + 'count' => 1, + 'price' => 50, + 'cost' => 50, + 'weight' => 0.1, + 'options' => [], + 'properties' => [], + ], + 'ms-a' => [ + 'id' => 10, + 'order_id' => 99, + 'product_id' => 1, + 'name' => 'First', + 'count' => 2, + 'price' => 100.456, + 'cost' => 200.456, + 'weight' => 0.1234, + 'options' => '{"color":"red"}', + 'properties' => [ + 'old_price' => 120, + 'discount_price' => 20, + 'discount_cost' => 40, + ], + ], + ], + 'status' => [ + 'total_positions' => 2, + 'total_count' => 3, + 'total_cost' => 250.456, + 'total_weight' => 0.3468, + 'total_discount' => 40, + ], + ]); + + self::assertCount(2, $out['items']); + self::assertSame(2, $out['status']['total_positions']); + self::assertSame('ms-a', $out['items'][0]['product_key']); + self::assertSame('ms-b', $out['items'][1]['product_key']); + self::assertSame(100.46, $out['items'][0]['price']); + self::assertSame(200.46, $out['items'][0]['cost']); + self::assertSame(0.123, $out['items'][0]['weight']); + self::assertSame(['color' => 'red'], $out['items'][0]['options']); + self::assertInstanceOf(\stdClass::class, $out['items'][1]['options']); + self::assertSame('{}', json_encode($out['items'][1]['options'])); + self::assertSame(120.0, $out['items'][0]['old_price']); + self::assertSame(20.0, $out['items'][0]['discount_price']); + self::assertSame(40.0, $out['items'][0]['discount_cost']); + self::assertArrayNotHasKey('thumb', $out['items'][0]); + self::assertArrayNotHasKey('order_id', $out['items'][0]); + self::assertArrayNotHasKey('hash', $out['items'][0]); + self::assertArrayNotHasKey('id', $out['items'][0]); + self::assertIsArray($out['cart']); + self::assertArrayHasKey('ms-a', $out['cart']); + self::assertSame(250.46, $out['status']['total_cost']); + self::assertSame(0.347, $out['status']['total_weight']); + self::assertSame(40.0, $out['status']['total_discount']); + } + + public function testDiscountCostFallsBackFromDiscountPriceTimesCount(): void + { + $out = $this->normalizer->normalize([ + 'cart' => [ + 'ms-x' => [ + 'product_id' => 3, + 'name' => 'Tea', + 'count' => 2, + 'price' => 100, + 'cost' => 200, + 'weight' => 0.2, + 'properties' => '{"old_price":120,"discount_price":20}', + ], + ], + 'status' => [], + ]); + + $item = $out['items'][0]; + self::assertSame(120.0, $item['old_price']); + self::assertSame(20.0, $item['discount_price']); + self::assertSame(40.0, $item['discount_cost']); + } + + public function testIncludeThumbsAddsUrlOrNull(): void + { + $normalizer = new class (new modX()) extends CartResponseNormalizer { + protected function lookupThumbs(array $productIds): array + { + return [12 => '/assets/small.jpg']; + } + }; + + $out = $normalizer->normalize( + [ + 'cart' => [ + 'ms-12' => [ + 'id' => 1, + 'product_id' => 12, + 'name' => 'Tea', + 'count' => 1, + 'price' => 10, + 'cost' => 10, + 'weight' => 0, + ], + 'ms-13' => [ + 'id' => 2, + 'product_id' => 13, + 'name' => 'Mug', + 'count' => 1, + 'price' => 5, + 'cost' => 5, + 'weight' => 0, + ], + ], + 'status' => ['total_positions' => 2, 'total_count' => 2], + ], + true + ); + + self::assertSame('/assets/small.jpg', $out['items'][0]['thumb']); + self::assertNull($out['items'][1]['thumb']); + } + + public function testWithoutThumbsFlagDoesNotQuery(): void + { + $normalizer = new class (new modX()) extends CartResponseNormalizer { + protected function lookupThumbs(array $productIds): array + { + throw new \RuntimeException('thumbs must not load without include_thumbs'); + } + }; + + $out = $normalizer->normalize([ + 'cart' => [ + 'ms-12' => [ + 'id' => 1, + 'product_id' => 12, + 'name' => 'Tea', + 'count' => 1, + 'price' => 10, + 'cost' => 10, + 'weight' => 0, + ], + ], + 'status' => ['total_positions' => 1, 'total_count' => 1], + ]); + + self::assertArrayNotHasKey('thumb', $out['items'][0]); + } +}