Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion assets/components/minishop3/js/web/core/ApiClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
20 changes: 10 additions & 10 deletions assets/components/minishop3/js/web/core/CartAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
* }
* }
Expand All @@ -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<Object>} - { 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<Object>} - { 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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'] ?? '';

Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions core/components/minishop3/src/ServiceRegistryFactories.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down
224 changes: 224 additions & 0 deletions core/components/minishop3/src/Services/Cart/CartResponseNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Cart;

use MiniShop3\Model\msProductData;
use MODX\Revolution\modX;

/**
* Web API projection for cart/get and cart mutations (#570).
*
* Does not change draft storage. Cart totals stay in CartItemManager::calculateStatus.
* Checkout delivery/payment/final live on GET /api/v1/order/cost, not here.
*/
class CartResponseNormalizer
{
private const MONEY_SCALE = 2;
private const WEIGHT_SCALE = 3;

public function __construct(
private modX $modx,
) {
}

/**
* @param array<string, mixed> $data Domain cart payload (cart + status)
* @return array<string, mixed>
*/
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<string, array<string, mixed>>
*/
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<string, array<string, mixed>> $cartMap
* @return list<array<string, mixed>>
*/
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<string, mixed> $raw
* @return array<string, mixed>
*/
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<string, mixed> $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<array<string, mixed>> $items
* @return list<array<string, mixed>>
*/
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<int> $productIds
* @return array<int, string>
*/
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<string, mixed>
*/
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 : [];
}
}
Loading