@pcreative/commerce-contract 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +66 -0
- package/package.json +37 -0
- package/src/adapters/api.d.ts +36 -0
- package/src/adapters/api.js +825 -0
- package/src/index.d.ts +397 -0
- package/src/index.js +80 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptador HTTP del contrato de datos.
|
|
3
|
+
*
|
|
4
|
+
* Habla con la API de tienda por `fetch` a pelo, sin SDK. No es purismo: un SDK
|
|
5
|
+
* trae su propio manejo de sesión y presupone un entorno de navegador, y eso es
|
|
6
|
+
* justo lo que impedía que un tema Astro, un runtime edge o una app nativa
|
|
7
|
+
* usaran el mismo código. `fetch` está en todos.
|
|
8
|
+
*
|
|
9
|
+
* import { createCommerce } from "@pcreative/commerce-contract/api"
|
|
10
|
+
* const commerce = createCommerce({
|
|
11
|
+
* baseUrl: process.env.COMMERCE_URL,
|
|
12
|
+
* publishableKey: process.env.COMMERCE_KEY,
|
|
13
|
+
* })
|
|
14
|
+
*/
|
|
15
|
+
import { money } from "../index.js"
|
|
16
|
+
|
|
17
|
+
const PRODUCT_FIELDS = [
|
|
18
|
+
"id", "title", "subtitle", "handle", "description", "thumbnail", "metadata",
|
|
19
|
+
"*images", "*categories", "*tags", "*variants", "*variants.calculated_price",
|
|
20
|
+
"+variants.inventory_quantity", "variants.options.value", "variants.options.option.title",
|
|
21
|
+
// Marketplace: quién vende el producto. Con `+` porque no es un campo de
|
|
22
|
+
// serie del catálogo — sale de un enlace entre módulos. Si la instalación no
|
|
23
|
+
// tiene marketplace, sencillamente no viene nada y `seller` queda vacío.
|
|
24
|
+
"+vendedor.id", "+vendedor.nombre", "+vendedor.slug",
|
|
25
|
+
].join(",")
|
|
26
|
+
|
|
27
|
+
const CART_FIELDS =
|
|
28
|
+
"*items,*items.variant,*items.product,*region,*shipping_address,*billing_address,*shipping_methods,*promotions,*payment_collection,*payment_collection.payment_sessions"
|
|
29
|
+
|
|
30
|
+
export function createCommerce({
|
|
31
|
+
baseUrl,
|
|
32
|
+
/**
|
|
33
|
+
* Usar el catálogo PROPIO (`/tienda/*`) en vez del del motor (`/store/*`).
|
|
34
|
+
*
|
|
35
|
+
* ── 🔴 POR QUÉ AHORA VIENE ENCENDIDO ────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* Venía APAGADO, y era el mismo fallo que ya se arregló en el panel el
|
|
38
|
+
* 2026-08-18 sin arreglarlo aquí. Aquel día `npm start` pasó a levantar el
|
|
39
|
+
* servidor propio, que sirve `/tienda/*` y NO sirve `/store/*`.
|
|
40
|
+
*
|
|
41
|
+
* Con el interruptor apagado por defecto, cualquier tema recién hecho pedía
|
|
42
|
+
* el catálogo al motor, recibía un 404 y salía con la tienda VACÍA. Y no
|
|
43
|
+
* daba un error: pintaba la portada entera, bien maquetada, sin un solo
|
|
44
|
+
* producto. Se descubrió probando un tema antes de publicarlo.
|
|
45
|
+
*
|
|
46
|
+
* Un valor por defecto que solo funciona si alguien se acuerda de cambiarlo
|
|
47
|
+
* no es un valor por defecto.
|
|
48
|
+
*
|
|
49
|
+
* Se pone a `false` a propósito para volver al motor, y va emparejado con
|
|
50
|
+
* `npm run start:motor` en el backend. Uno sin el otro es media vuelta
|
|
51
|
+
* atrás, que es peor que ninguna.
|
|
52
|
+
*/
|
|
53
|
+
catalogoPropio = true,
|
|
54
|
+
publishableKey,
|
|
55
|
+
/** Código de país de la región por defecto, p.ej. "es". */
|
|
56
|
+
countryCode,
|
|
57
|
+
/**
|
|
58
|
+
* Idioma en el que se quiere el contenido, p.ej. "en-US".
|
|
59
|
+
*
|
|
60
|
+
* Va en una cabecera y no en cada llamada a propósito: el idioma es del
|
|
61
|
+
* VISITANTE, no de la consulta. Si hubiera que pasarlo función por función,
|
|
62
|
+
* bastaría con que un tema se olvidara en una sola pantalla para que esa
|
|
63
|
+
* página saliera en otro idioma — y ese fallo es de los que no se ven hasta
|
|
64
|
+
* que lo dice un cliente.
|
|
65
|
+
*
|
|
66
|
+
* Lo que no esté traducido vuelve en el idioma original, así que poner un
|
|
67
|
+
* idioma nunca deja la tienda vacía.
|
|
68
|
+
*/
|
|
69
|
+
locale,
|
|
70
|
+
/** `fetch` alternativo (para tests o para inyectar caché del framework). */
|
|
71
|
+
fetch: fetchImpl,
|
|
72
|
+
/** Opciones extra por petición: `next: { revalidate }` en Next, `cache` en el edge… */
|
|
73
|
+
requestInit = {},
|
|
74
|
+
} = {}) {
|
|
75
|
+
if (!baseUrl) throw new Error("createCommerce: falta baseUrl")
|
|
76
|
+
const f = fetchImpl ?? globalThis.fetch
|
|
77
|
+
const raiz = String(baseUrl).replace(/\/$/, "")
|
|
78
|
+
|
|
79
|
+
let regionPromesa = null
|
|
80
|
+
|
|
81
|
+
async function api(ruta, { method = "GET", body, token, ...extra } = {}) {
|
|
82
|
+
const cabeceras = { accept: "application/json", ...(extra.headers ?? {}) }
|
|
83
|
+
if (publishableKey) cabeceras["x-publishable-api-key"] = publishableKey
|
|
84
|
+
if (locale) cabeceras["x-pcc-locale"] = locale
|
|
85
|
+
if (body !== undefined) cabeceras["content-type"] = "application/json"
|
|
86
|
+
if (token) cabeceras.authorization = `Bearer ${token}`
|
|
87
|
+
|
|
88
|
+
const res = await f(`${raiz}${ruta}`, {
|
|
89
|
+
method,
|
|
90
|
+
...requestInit,
|
|
91
|
+
...extra,
|
|
92
|
+
headers: cabeceras,
|
|
93
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
let detalle = ""
|
|
98
|
+
try {
|
|
99
|
+
detalle = (await res.json())?.message ?? ""
|
|
100
|
+
} catch {
|
|
101
|
+
/* respuesta sin JSON */
|
|
102
|
+
}
|
|
103
|
+
// El código HTTP viaja en el error: un 404 de producto no es lo mismo que
|
|
104
|
+
// un 401 por clave publicable mal puesta, y el tema debe poder distinguirlo.
|
|
105
|
+
const err = new Error(`${method} ${ruta} → ${res.status}${detalle ? `: ${detalle}` : ""}`)
|
|
106
|
+
err.status = res.status
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
return res.status === 204 ? null : res.json()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** La región fija moneda e impuestos. Se resuelve una vez y se reutiliza. */
|
|
113
|
+
function region() {
|
|
114
|
+
// Con el catálogo propio, las regiones también salen de nuestras rutas: si
|
|
115
|
+
// no, la tienda leería la moneda del motor y los precios del nuestro.
|
|
116
|
+
regionPromesa ??= api(catalogoPropio ? "/tienda/regiones" : "/store/regions")
|
|
117
|
+
.then(({ regions = [] }) => {
|
|
118
|
+
if (countryCode) {
|
|
119
|
+
const r = regions.find((x) => (x.countries ?? []).some((c) => c.iso_2 === countryCode.toLowerCase()))
|
|
120
|
+
if (r) return r
|
|
121
|
+
}
|
|
122
|
+
return regions[0] ?? null
|
|
123
|
+
})
|
|
124
|
+
.catch(() => null)
|
|
125
|
+
return regionPromesa
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Mapeo ───────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
const monedaDe = (v, fallback) =>
|
|
131
|
+
(v?.calculated_price?.currency_code ?? fallback ?? "eur").toLowerCase()
|
|
132
|
+
|
|
133
|
+
function mapVariant(v, fallbackCurrency) {
|
|
134
|
+
const cp = v?.calculated_price ?? {}
|
|
135
|
+
const precio = money(cp.calculated_amount, monedaDe(v, fallbackCurrency), true)
|
|
136
|
+
const original = cp.original_amount
|
|
137
|
+
return {
|
|
138
|
+
id: v.id,
|
|
139
|
+
title: v.title ?? "",
|
|
140
|
+
sku: v.sku ?? undefined,
|
|
141
|
+
barcode: v.barcode ?? undefined,
|
|
142
|
+
price: precio,
|
|
143
|
+
compareAt:
|
|
144
|
+
original != null && original > (cp.calculated_amount ?? 0)
|
|
145
|
+
? money(original, precio.currency, true)
|
|
146
|
+
: null,
|
|
147
|
+
// `inventory_quantity` solo viene si la variante gestiona inventario;
|
|
148
|
+
// si no, no hay número que enseñar y `null` es la respuesta honesta.
|
|
149
|
+
stock: v.manage_inventory === false ? null : v.inventory_quantity ?? null,
|
|
150
|
+
available:
|
|
151
|
+
v.manage_inventory === false ||
|
|
152
|
+
v.allow_backorder === true ||
|
|
153
|
+
(v.inventory_quantity ?? 0) > 0,
|
|
154
|
+
options: (v.options ?? []).map((o) => ({
|
|
155
|
+
name: o.option?.title ?? "",
|
|
156
|
+
value: o.value ?? "",
|
|
157
|
+
})),
|
|
158
|
+
weight: v.weight ?? null,
|
|
159
|
+
image: null,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function mapProduct(p, fallbackCurrency) {
|
|
164
|
+
const variants = (p.variants ?? []).map((v) => mapVariant(v, fallbackCurrency))
|
|
165
|
+
const conPrecio = variants.filter((v) => v.price.amount > 0)
|
|
166
|
+
const minimo = conPrecio.length
|
|
167
|
+
? conPrecio.reduce((a, b) => (b.price.amount < a.price.amount ? b : a))
|
|
168
|
+
: variants[0]
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
id: p.id,
|
|
172
|
+
handle: p.handle,
|
|
173
|
+
title: p.title,
|
|
174
|
+
subtitle: p.subtitle ?? null,
|
|
175
|
+
description: p.description ?? null,
|
|
176
|
+
brand: p.metadata?.brand ?? p.metadata?.marca ?? null,
|
|
177
|
+
// El enlace puede llegar como objeto o como lista de uno, según por dónde
|
|
178
|
+
// se pida. Se normaliza aquí para que el tema no tenga que saberlo.
|
|
179
|
+
seller: (() => {
|
|
180
|
+
const v = Array.isArray(p.vendedor) ? p.vendedor[0] : p.vendedor
|
|
181
|
+
return v?.id ? { id: v.id, name: v.nombre, slug: v.slug } : null
|
|
182
|
+
})(),
|
|
183
|
+
images: (p.images ?? []).map((i) => ({ url: i.url, alt: p.title })),
|
|
184
|
+
thumbnail: p.thumbnail ?? p.images?.[0]?.url ?? null,
|
|
185
|
+
price: minimo?.price ?? money(0, fallbackCurrency ?? "eur", true),
|
|
186
|
+
variants,
|
|
187
|
+
categories: (p.categories ?? []).map((c) => ({ id: c.id, handle: c.handle, name: c.name })),
|
|
188
|
+
tags: (p.tags ?? []).map((t) => t.value ?? t.name).filter(Boolean),
|
|
189
|
+
rating: null,
|
|
190
|
+
reviewCount: 0,
|
|
191
|
+
available: variants.some((v) => v.available),
|
|
192
|
+
metadata: p.metadata ?? {},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mapCategory(c) {
|
|
197
|
+
return {
|
|
198
|
+
id: c.id,
|
|
199
|
+
handle: c.handle,
|
|
200
|
+
name: c.name,
|
|
201
|
+
description: c.description ?? null,
|
|
202
|
+
parentId: c.parent_category_id ?? null,
|
|
203
|
+
children: (c.category_children ?? []).map(mapCategory),
|
|
204
|
+
image: c.metadata?.image ? { url: String(c.metadata.image), alt: c.name } : null,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Del producto que devuelve NUESTRO catálogo al del contrato.
|
|
210
|
+
*
|
|
211
|
+
* Viene casi con la forma correcta a propósito: el catálogo propio se
|
|
212
|
+
* escribió para hablar el contrato, no el idioma del motor.
|
|
213
|
+
*/
|
|
214
|
+
function mapProductoPropio(p) {
|
|
215
|
+
return {
|
|
216
|
+
id: p.id,
|
|
217
|
+
handle: p.handle,
|
|
218
|
+
title: p.title,
|
|
219
|
+
subtitle: p.subtitle ?? null,
|
|
220
|
+
description: p.description ?? null,
|
|
221
|
+
// El `alt` se pone aquí, como en el camino del motor: una imagen sin
|
|
222
|
+
// texto alternativo es una ficha que no se puede leer con un lector de
|
|
223
|
+
// pantalla y que el buscador no entiende.
|
|
224
|
+
images: (p.images ?? []).map((i) => ({ url: i.url, alt: i.alt ?? p.title })),
|
|
225
|
+
thumbnail: p.thumbnail ?? null,
|
|
226
|
+
price: p.price,
|
|
227
|
+
// Los cuatro que faltaban. No son adorno: la marca y las etiquetas las
|
|
228
|
+
// pinta la ficha, y `reviewCount` decide si sale el bloque de reseñas.
|
|
229
|
+
// Al no venir, un tema los leía como `undefined` y dejaba huecos sin
|
|
230
|
+
// dar ningún error.
|
|
231
|
+
brand: p.brand ?? p.metadata?.brand ?? p.metadata?.marca ?? null,
|
|
232
|
+
tags: p.tags ?? [],
|
|
233
|
+
rating: p.rating ?? null,
|
|
234
|
+
reviewCount: p.reviewCount ?? 0,
|
|
235
|
+
variants: (p.variants ?? []).map((v) => ({
|
|
236
|
+
id: v.id,
|
|
237
|
+
title: v.title,
|
|
238
|
+
sku: v.sku ?? undefined,
|
|
239
|
+
barcode: v.barcode ?? undefined,
|
|
240
|
+
price: v.price,
|
|
241
|
+
compareAt: v.compareAt ?? null,
|
|
242
|
+
// `null` = esa variante no lleva la cuenta unidad a unidad. NO es cero:
|
|
243
|
+
// decir cero sería afirmar que no queda, y el tema lo pintaría agotado.
|
|
244
|
+
stock: v.stock ?? null,
|
|
245
|
+
available: v.available,
|
|
246
|
+
options: v.options ?? [],
|
|
247
|
+
})),
|
|
248
|
+
categories: p.categories ?? [],
|
|
249
|
+
available: p.available,
|
|
250
|
+
metadata: p.metadata,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function mapLineItem(i, currency) {
|
|
255
|
+
return {
|
|
256
|
+
id: i.id,
|
|
257
|
+
productId: i.product_id ?? i.product?.id ?? "",
|
|
258
|
+
variantId: i.variant_id ?? i.variant?.id ?? "",
|
|
259
|
+
handle: i.product?.handle ?? i.product_handle ?? undefined,
|
|
260
|
+
title: i.product_title ?? i.title ?? "",
|
|
261
|
+
variantTitle: i.variant_title ?? i.variant?.title ?? undefined,
|
|
262
|
+
thumbnail: i.thumbnail ?? null,
|
|
263
|
+
quantity: i.quantity ?? 0,
|
|
264
|
+
unitPrice: money(i.unit_price, currency, true),
|
|
265
|
+
total: money(i.total ?? (i.unit_price ?? 0) * (i.quantity ?? 0), currency, true),
|
|
266
|
+
// Un CARGO, no mercancía: el recargo por forma de pago, por ejemplo. No
|
|
267
|
+
// tiene variante y no se envía. El tema lo necesita para no contarlo dos
|
|
268
|
+
// veces cuando además lo enseña por separado.
|
|
269
|
+
isFee: i.requires_shipping === false && !(i.variant_id ?? i.variant?.id),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function mapCart(c) {
|
|
274
|
+
if (!c) return null
|
|
275
|
+
const currency = (c.currency_code ?? c.region?.currency_code ?? "eur").toLowerCase()
|
|
276
|
+
const items = (c.items ?? []).map((i) => mapLineItem(i, currency))
|
|
277
|
+
return {
|
|
278
|
+
id: c.id,
|
|
279
|
+
items,
|
|
280
|
+
subtotal: money(c.item_subtotal ?? c.subtotal, currency, true),
|
|
281
|
+
shipping: money(c.shipping_total, currency, true),
|
|
282
|
+
discount: money(c.discount_total, currency, true),
|
|
283
|
+
tax: money(c.tax_total, currency, true),
|
|
284
|
+
total: money(c.total, currency, true),
|
|
285
|
+
itemCount: items.reduce((n, i) => n + i.quantity, 0),
|
|
286
|
+
email: c.email ?? null,
|
|
287
|
+
shippingAddress: mapAddress(c.shipping_address),
|
|
288
|
+
billingAddress: mapAddress(c.billing_address),
|
|
289
|
+
shippingMethods: (c.shipping_methods ?? []).map((m) => ({
|
|
290
|
+
id: m.shipping_option_id ?? m.id,
|
|
291
|
+
name: m.name ?? "",
|
|
292
|
+
price: money(m.amount, currency, true),
|
|
293
|
+
})),
|
|
294
|
+
regionId: c.region_id ?? c.region?.id,
|
|
295
|
+
promoCodes: (c.promotions ?? []).map((p) => p.code).filter(Boolean),
|
|
296
|
+
pago: siguientePago(c),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Cómo tiene que seguir el comprador para pagar.
|
|
302
|
+
*
|
|
303
|
+
* Las pasarelas se reparten en dos familias: unas mandan al comprador a su
|
|
304
|
+
* página y otras le hacen confirmar en el navegador con un secreto. Sin esto
|
|
305
|
+
* el tema no puede hacer ninguna de las dos, y el comprador se queda mirando
|
|
306
|
+
* un botón que no lleva a ningún sitio.
|
|
307
|
+
*
|
|
308
|
+
* El dato lo pone el proveedor de pago del backend en la sesión. Los métodos
|
|
309
|
+
* sin pasarela —transferencia, contrarreembolso— no ponen nada, y entonces
|
|
310
|
+
* pagar es simplemente confirmar el pedido.
|
|
311
|
+
*/
|
|
312
|
+
function siguientePago(c) {
|
|
313
|
+
const sesiones = c?.payment_collection?.payment_sessions ?? []
|
|
314
|
+
// La última pendiente: si alguien cambia de método, la anterior queda ahí
|
|
315
|
+
// y usarla mandaría a pagar por una pasarela que ya descartó.
|
|
316
|
+
const sesion = [...sesiones].reverse().find((s) => s.status === "pending" || s.status === "requires_more")
|
|
317
|
+
const paso = sesion?.data?.siguiente
|
|
318
|
+
if (!paso || typeof paso !== "object" || !paso.tipo) return null
|
|
319
|
+
if (paso.tipo === "redirigir" && typeof paso.url === "string") {
|
|
320
|
+
return { tipo: "redirigir", url: paso.url, proveedor: sesion.provider_id }
|
|
321
|
+
}
|
|
322
|
+
if (paso.tipo === "confirmar_en_cliente") {
|
|
323
|
+
return {
|
|
324
|
+
tipo: "confirmar_en_cliente",
|
|
325
|
+
pasarela: String(paso.pasarela ?? ""),
|
|
326
|
+
datos: paso.datos ?? {},
|
|
327
|
+
proveedor: sesion.provider_id,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function mapAddress(a) {
|
|
334
|
+
if (!a) return null
|
|
335
|
+
return {
|
|
336
|
+
firstName: a.first_name ?? undefined,
|
|
337
|
+
lastName: a.last_name ?? undefined,
|
|
338
|
+
company: a.company ?? undefined,
|
|
339
|
+
address1: a.address_1 ?? undefined,
|
|
340
|
+
address2: a.address_2 ?? undefined,
|
|
341
|
+
city: a.city ?? undefined,
|
|
342
|
+
province: a.province ?? undefined,
|
|
343
|
+
postalCode: a.postal_code ?? undefined,
|
|
344
|
+
countryCode: a.country_code ?? undefined,
|
|
345
|
+
phone: a.phone ?? undefined,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const aDireccionApi = (a = {}) => ({
|
|
350
|
+
first_name: a.firstName,
|
|
351
|
+
last_name: a.lastName,
|
|
352
|
+
company: a.company,
|
|
353
|
+
address_1: a.address1,
|
|
354
|
+
address_2: a.address2,
|
|
355
|
+
city: a.city,
|
|
356
|
+
province: a.province,
|
|
357
|
+
postal_code: a.postalCode,
|
|
358
|
+
country_code: a.countryCode?.toLowerCase(),
|
|
359
|
+
phone: a.phone,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
function mapOrder(o) {
|
|
363
|
+
const currency = (o.currency_code ?? "eur").toLowerCase()
|
|
364
|
+
return {
|
|
365
|
+
id: o.id,
|
|
366
|
+
displayId: o.display_id,
|
|
367
|
+
email: o.email,
|
|
368
|
+
status: o.status ?? "pending",
|
|
369
|
+
items: (o.items ?? []).map((i) => mapLineItem(i, currency)),
|
|
370
|
+
subtotal: money(o.item_subtotal ?? o.subtotal, currency, true),
|
|
371
|
+
shipping: money(o.shipping_total, currency, true),
|
|
372
|
+
discount: money(o.discount_total, currency, true),
|
|
373
|
+
tax: money(o.tax_total, currency, true),
|
|
374
|
+
total: money(o.total, currency, true),
|
|
375
|
+
shippingAddress: mapAddress(o.shipping_address),
|
|
376
|
+
createdAt: o.created_at,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Catálogo ────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
async function listProducts(params = {}) {
|
|
383
|
+
if (catalogoPropio) {
|
|
384
|
+
const q = new URLSearchParams()
|
|
385
|
+
if (params.limit) q.set("limite", String(params.limit))
|
|
386
|
+
if (params.offset) q.set("desde", String(params.offset))
|
|
387
|
+
if (params.categoryId) q.set("categoria", params.categoryId)
|
|
388
|
+
// 🔴 EL TÉRMINO DE BÚSQUEDA. Sin esta línea `search()` devolvía el
|
|
389
|
+
// CATÁLOGO ENTERO para cualquier cosa que se escribiera: el buscador
|
|
390
|
+
// parecía funcionar —salían productos— y no eran los buscados. No daba
|
|
391
|
+
// ningún error; simplemente el buscador no buscaba.
|
|
392
|
+
if (params.q) q.set("q", String(params.q))
|
|
393
|
+
const r = await api(`/tienda/productos?${q}`)
|
|
394
|
+
return {
|
|
395
|
+
items: (r.productos ?? []).map(mapProductoPropio),
|
|
396
|
+
count: r.total ?? 0,
|
|
397
|
+
limit: params.limit ?? 24,
|
|
398
|
+
offset: params.offset ?? 0,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const r = await region()
|
|
402
|
+
const limit = params.limit ?? 24
|
|
403
|
+
const offset = params.offset ?? 0
|
|
404
|
+
|
|
405
|
+
let categoryId = params.categoryId
|
|
406
|
+
if (!categoryId && params.category) {
|
|
407
|
+
const c = await getCategoryRaw(params.category)
|
|
408
|
+
if (!c) return { items: [], count: 0, limit, offset }
|
|
409
|
+
categoryId = c.id
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const qs = new URLSearchParams({ limit: String(limit), offset: String(offset), fields: PRODUCT_FIELDS })
|
|
413
|
+
if (r?.id) qs.set("region_id", r.id)
|
|
414
|
+
if (params.q) qs.set("q", params.q)
|
|
415
|
+
if (categoryId) qs.append("category_id[]", categoryId)
|
|
416
|
+
for (const id of params.ids ?? []) qs.append("id[]", id)
|
|
417
|
+
if (params.sort === "newest") qs.set("order", "-created_at")
|
|
418
|
+
|
|
419
|
+
const { products = [], count = 0 } = await api(`/store/products?${qs}`)
|
|
420
|
+
let items = products.map((p) => mapProduct(p, r?.currency_code))
|
|
421
|
+
|
|
422
|
+
// Ordenar por precio en el cliente sería mentir: solo ordenaría la página
|
|
423
|
+
// actual. Se delega en el endpoint del backend, y si no está, se avisa.
|
|
424
|
+
if (params.sort === "price_asc" || params.sort === "price_desc") {
|
|
425
|
+
const ordenados = await idsPorPrecio({ ...params, limit, offset, categoryId, region: r })
|
|
426
|
+
// Se exige la forma esperada, no solo que la respuesta no sea nula: un
|
|
427
|
+
// proxy mal configurado puede contestar 200 con cualquier otra cosa.
|
|
428
|
+
if (Array.isArray(ordenados?.ids)) {
|
|
429
|
+
const porId = new Map(items.map((p) => [p.id, p]))
|
|
430
|
+
const faltantes = ordenados.ids.filter((id) => !porId.has(id))
|
|
431
|
+
if (faltantes.length) {
|
|
432
|
+
for (const p of await getProductsByIds(faltantes)) porId.set(p.id, p)
|
|
433
|
+
}
|
|
434
|
+
items = ordenados.ids.map((id) => porId.get(id)).filter(Boolean)
|
|
435
|
+
return { items, count: ordenados.count, limit, offset }
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { items, count, limit, offset }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* La Store API no sabe ordenar por precio (los precios viven en otro módulo y
|
|
444
|
+
* `order=variants.calculated_price` devuelve 500). El backend expone
|
|
445
|
+
* `/tienda/products-by-price`; si no está, se devuelve null y el llamador se
|
|
446
|
+
* queda con el orden por defecto en vez de romper.
|
|
447
|
+
*/
|
|
448
|
+
async function idsPorPrecio({ q, categoryId, sort, limit, offset, region: r }) {
|
|
449
|
+
const qs = new URLSearchParams({
|
|
450
|
+
limit: String(limit),
|
|
451
|
+
offset: String(offset),
|
|
452
|
+
order: sort === "price_desc" ? "desc" : "asc",
|
|
453
|
+
})
|
|
454
|
+
if (q) qs.set("q", q)
|
|
455
|
+
if (categoryId) qs.set("category_id", categoryId)
|
|
456
|
+
if (r?.currency_code) qs.set("currency_code", r.currency_code)
|
|
457
|
+
try {
|
|
458
|
+
return await api(`/tienda/products-by-price?${qs}`)
|
|
459
|
+
} catch {
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function getProduct(handle) {
|
|
465
|
+
if (catalogoPropio) {
|
|
466
|
+
try {
|
|
467
|
+
const r = await api(`/tienda/productos/${encodeURIComponent(handle)}`)
|
|
468
|
+
return r.producto ? mapProductoPropio(r.producto) : null
|
|
469
|
+
} catch (e) {
|
|
470
|
+
// Un 404 aquí es «no existe», no un fallo: el escaparate lo trata como
|
|
471
|
+
// página no encontrada, no como tienda caída.
|
|
472
|
+
if (e?.status === 404) return null
|
|
473
|
+
throw e
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const r = await region()
|
|
477
|
+
const qs = new URLSearchParams({ handle, limit: "1", fields: PRODUCT_FIELDS })
|
|
478
|
+
if (r?.id) qs.set("region_id", r.id)
|
|
479
|
+
const { products = [] } = await api(`/store/products?${qs}`)
|
|
480
|
+
return products[0] ? mapProduct(products[0], r?.currency_code) : null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function getProductsByIds(ids) {
|
|
484
|
+
if (!ids?.length) return []
|
|
485
|
+
const r = await region()
|
|
486
|
+
const qs = new URLSearchParams({ limit: String(ids.length), fields: PRODUCT_FIELDS })
|
|
487
|
+
if (r?.id) qs.set("region_id", r.id)
|
|
488
|
+
for (const id of ids) qs.append("id[]", id)
|
|
489
|
+
const { products = [] } = await api(`/store/products?${qs}`)
|
|
490
|
+
return products.map((p) => mapProduct(p, r?.currency_code))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function getCategoryRaw(handle) {
|
|
494
|
+
if (catalogoPropio) {
|
|
495
|
+
const { product_categories = [] } = await api(`/tienda/categorias?handle=${encodeURIComponent(handle)}`)
|
|
496
|
+
return product_categories[0] ?? null
|
|
497
|
+
}
|
|
498
|
+
const qs = new URLSearchParams({ handle, limit: "1", fields: "id,name,handle,description,parent_category_id,metadata" })
|
|
499
|
+
const { product_categories = [] } = await api(`/store/product-categories?${qs}`)
|
|
500
|
+
return product_categories[0] ?? null
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function listCategories() {
|
|
504
|
+
if (catalogoPropio) {
|
|
505
|
+
const { product_categories = [] } = await api("/tienda/categorias")
|
|
506
|
+
// 🔴 Solo las de primer nivel: vienen planas Y anidadas a la vez, así que
|
|
507
|
+
// devolverlas todas duplicaría cada hija dentro y fuera de su madre, y un
|
|
508
|
+
// menú saldría con las subcategorías repetidas al final.
|
|
509
|
+
return product_categories.filter((c) => !c.parent_category_id).map(mapCategory)
|
|
510
|
+
}
|
|
511
|
+
const qs = new URLSearchParams({
|
|
512
|
+
limit: "200",
|
|
513
|
+
fields: "id,name,handle,description,parent_category_id,metadata,*category_children",
|
|
514
|
+
})
|
|
515
|
+
const { product_categories = [] } = await api(`/store/product-categories?${qs}`)
|
|
516
|
+
return product_categories.map(mapCategory)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function getCategory(handle) {
|
|
520
|
+
const c = await getCategoryRaw(handle)
|
|
521
|
+
return c ? mapCategory(c) : null
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Pedido por id, para la página de confirmación. */
|
|
525
|
+
async function getOrder(id) {
|
|
526
|
+
try {
|
|
527
|
+
const { order } = await api(`/store/orders/${id}`)
|
|
528
|
+
return mapOrder(order)
|
|
529
|
+
} catch (e) {
|
|
530
|
+
if (e.status === 404 || e.status === 401) return null
|
|
531
|
+
throw e
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function search(q, limit = 24) {
|
|
536
|
+
const { items } = await listProducts({ q, limit })
|
|
537
|
+
return items
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── Carrito ─────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
const getCart = async (id) => {
|
|
543
|
+
try {
|
|
544
|
+
const { cart } = await api(`/store/carts/${id}?fields=${encodeURIComponent(CART_FIELDS)}`)
|
|
545
|
+
return mapCart(cart)
|
|
546
|
+
} catch (e) {
|
|
547
|
+
// Un carrito caducado o completado es un caso normal, no un fallo: el
|
|
548
|
+
// tema debe crear uno nuevo, no enseñar una pantalla de error.
|
|
549
|
+
if (e.status === 404) return null
|
|
550
|
+
throw e
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const cart = {
|
|
555
|
+
get: getCart,
|
|
556
|
+
async create() {
|
|
557
|
+
const r = await region()
|
|
558
|
+
const { cart } = await api("/store/carts", { method: "POST", body: r?.id ? { region_id: r.id } : {} })
|
|
559
|
+
return mapCart(cart)
|
|
560
|
+
},
|
|
561
|
+
async addItem(cartId, variantId, quantity) {
|
|
562
|
+
const { cart } = await api(`/store/carts/${cartId}/line-items`, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
body: { variant_id: variantId, quantity },
|
|
565
|
+
})
|
|
566
|
+
return mapCart(cart)
|
|
567
|
+
},
|
|
568
|
+
async updateItem(cartId, lineId, quantity) {
|
|
569
|
+
if (quantity <= 0) return cart.removeItem(cartId, lineId)
|
|
570
|
+
const { cart: c } = await api(`/store/carts/${cartId}/line-items/${lineId}`, {
|
|
571
|
+
method: "POST",
|
|
572
|
+
body: { quantity },
|
|
573
|
+
})
|
|
574
|
+
return mapCart(c)
|
|
575
|
+
},
|
|
576
|
+
async removeItem(cartId, lineId) {
|
|
577
|
+
await api(`/store/carts/${cartId}/line-items/${lineId}`, { method: "DELETE" })
|
|
578
|
+
return getCart(cartId)
|
|
579
|
+
},
|
|
580
|
+
async applyPromo(cartId, code) {
|
|
581
|
+
const actual = await getCart(cartId)
|
|
582
|
+
const { cart } = await api(`/store/carts/${cartId}`, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
body: { promo_codes: [...new Set([...(actual?.promoCodes ?? []), code])] },
|
|
585
|
+
})
|
|
586
|
+
return mapCart(cart)
|
|
587
|
+
},
|
|
588
|
+
async removePromo(cartId, code) {
|
|
589
|
+
const actual = await getCart(cartId)
|
|
590
|
+
const { cart } = await api(`/store/carts/${cartId}`, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
body: { promo_codes: (actual?.promoCodes ?? []).filter((c) => c !== code) },
|
|
593
|
+
})
|
|
594
|
+
return mapCart(cart)
|
|
595
|
+
},
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── Checkout ────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
const checkout = {
|
|
601
|
+
async setEmail(cartId, email) {
|
|
602
|
+
const { cart } = await api(`/store/carts/${cartId}`, { method: "POST", body: { email } })
|
|
603
|
+
return mapCart(cart)
|
|
604
|
+
},
|
|
605
|
+
async setAddresses(cartId, shipping, billing) {
|
|
606
|
+
const { cart } = await api(`/store/carts/${cartId}`, {
|
|
607
|
+
method: "POST",
|
|
608
|
+
body: {
|
|
609
|
+
shipping_address: aDireccionApi(shipping),
|
|
610
|
+
billing_address: aDireccionApi(billing ?? shipping),
|
|
611
|
+
},
|
|
612
|
+
})
|
|
613
|
+
return mapCart(cart)
|
|
614
|
+
},
|
|
615
|
+
async listShippingOptions(cartId) {
|
|
616
|
+
const { shipping_options = [] } = await api(`/store/shipping-options?cart_id=${cartId}`)
|
|
617
|
+
return shipping_options.map((o) => ({
|
|
618
|
+
id: o.id,
|
|
619
|
+
name: o.name,
|
|
620
|
+
price: money(o.amount ?? o.calculated_price?.calculated_amount, o.calculated_price?.currency_code ?? "eur", true),
|
|
621
|
+
priceCalculated: o.price_type === "calculated",
|
|
622
|
+
description: o.data?.description ?? null,
|
|
623
|
+
}))
|
|
624
|
+
},
|
|
625
|
+
/**
|
|
626
|
+
* De cuántos sitios sale el carrito.
|
|
627
|
+
*
|
|
628
|
+
* Devuelve `null` si el backend no tiene marketplace montado: la ruta no
|
|
629
|
+
* existe y responde 404. El tema lo interpreta como «una tienda normal» y
|
|
630
|
+
* sigue con el selector de envío de siempre, sin enterarse de nada.
|
|
631
|
+
*/
|
|
632
|
+
async listShippingGroups(cartId) {
|
|
633
|
+
try {
|
|
634
|
+
return await api(`/tienda/marketplace/envios?cart_id=${cartId}`)
|
|
635
|
+
} catch {
|
|
636
|
+
return null
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
/**
|
|
640
|
+
* Los portes de todos los grupos, en UNA llamada.
|
|
641
|
+
*
|
|
642
|
+
* Mandarlos de uno en uno no vale: el segundo borra al primero.
|
|
643
|
+
* Lo impone la ruta del backend, que rechaza un conjunto incompleto en vez
|
|
644
|
+
* de cobrar de menos calladamente.
|
|
645
|
+
*/
|
|
646
|
+
async setShippingMethods(cartId, elecciones) {
|
|
647
|
+
await api("/tienda/marketplace/envios", {
|
|
648
|
+
method: "POST",
|
|
649
|
+
body: {
|
|
650
|
+
cart_id: cartId,
|
|
651
|
+
elecciones: elecciones.map((e) => ({ grupo: e.grupo, option_id: e.optionId })),
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
},
|
|
655
|
+
async setShippingMethod(cartId, optionId) {
|
|
656
|
+
const { cart } = await api(`/store/carts/${cartId}/shipping-methods`, {
|
|
657
|
+
method: "POST",
|
|
658
|
+
body: { option_id: optionId },
|
|
659
|
+
})
|
|
660
|
+
return mapCart(cart)
|
|
661
|
+
},
|
|
662
|
+
async listPaymentMethods(cartId) {
|
|
663
|
+
const c = await getCart(cartId)
|
|
664
|
+
const r = c?.regionId ?? (await region())?.id
|
|
665
|
+
if (!r) return []
|
|
666
|
+
const { payment_providers = [] } = await api(`/store/payment-providers?region_id=${r}`)
|
|
667
|
+
// El recargo configurado en la tienda, para poder enseñarlo junto al
|
|
668
|
+
// método. Si la ruta no existe (backend anterior), simplemente no hay.
|
|
669
|
+
let cfg = {}
|
|
670
|
+
try {
|
|
671
|
+
const r2 = await api("/payment-config")
|
|
672
|
+
cfg = r2?.methods ?? {}
|
|
673
|
+
} catch {}
|
|
674
|
+
return payment_providers.map((p) => ({
|
|
675
|
+
id: p.id,
|
|
676
|
+
provider: p.id,
|
|
677
|
+
name: cfg[p.id]?.name || p.id,
|
|
678
|
+
...(cfg[p.id]?.surcharge ? { surcharge: Number(cfg[p.id].surcharge) } : {}),
|
|
679
|
+
}))
|
|
680
|
+
},
|
|
681
|
+
async selectPaymentMethod(cartId, provider, datos) {
|
|
682
|
+
// El recargo de la forma de pago, ANTES de crear el cobro: el importe a
|
|
683
|
+
// cobrar sale del total del carrito. Al revés, el recargo entraría en el
|
|
684
|
+
// pedido pero no en lo que se le cobra al cliente.
|
|
685
|
+
//
|
|
686
|
+
// Si el backend no tiene la ruta (versión anterior), no hay recargos y se
|
|
687
|
+
// sigue igual que siempre.
|
|
688
|
+
try {
|
|
689
|
+
await api("/tienda/pagos/recargo", {
|
|
690
|
+
method: "POST",
|
|
691
|
+
body: { cart_id: cartId, provider_id: provider },
|
|
692
|
+
})
|
|
693
|
+
} catch {}
|
|
694
|
+
|
|
695
|
+
const { payment_collection } = await api("/store/payment-collections", {
|
|
696
|
+
method: "POST",
|
|
697
|
+
body: { cart_id: cartId },
|
|
698
|
+
})
|
|
699
|
+
await api(`/store/payment-collections/${payment_collection.id}/payment-sessions`, {
|
|
700
|
+
method: "POST",
|
|
701
|
+
// `data` lo lee el proveedor de pago del backend. Sirve para decirle
|
|
702
|
+
// cosas que solo sabe el escaparate — por ejemplo a qué RUTA debe
|
|
703
|
+
// volver el comprador, que cambia con el idioma de la página.
|
|
704
|
+
body: { provider_id: provider, ...(datos ? { data: datos } : {}) },
|
|
705
|
+
})
|
|
706
|
+
return getCart(cartId)
|
|
707
|
+
},
|
|
708
|
+
async complete(cartId) {
|
|
709
|
+
const r = await api(`/store/carts/${cartId}/complete`, { method: "POST" })
|
|
710
|
+
// El backend contesta con el pedido o, si algo falló al cobrar, con el carrito
|
|
711
|
+
// intacto y el motivo. Las dos son respuestas 200: el tema tiene que
|
|
712
|
+
// mirar el tipo, no el código HTTP.
|
|
713
|
+
if (r?.type === "order" && r.order) return { order: mapOrder(r.order) }
|
|
714
|
+
return { cart: mapCart(r?.cart), error: r?.error?.message ?? "No se pudo completar el pedido" }
|
|
715
|
+
},
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Cuenta y contenido ──────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
const account = {
|
|
721
|
+
async login(email, password) {
|
|
722
|
+
const r = await api("/auth/customer/emailpass", { method: "POST", body: { email, password } })
|
|
723
|
+
return { token: r.token }
|
|
724
|
+
},
|
|
725
|
+
async register({ email, password, firstName, lastName }) {
|
|
726
|
+
const { token } = await api("/auth/customer/emailpass/register", {
|
|
727
|
+
method: "POST",
|
|
728
|
+
body: { email, password },
|
|
729
|
+
})
|
|
730
|
+
const { customer } = await api("/store/customers", {
|
|
731
|
+
method: "POST",
|
|
732
|
+
token,
|
|
733
|
+
body: { email, first_name: firstName, last_name: lastName },
|
|
734
|
+
})
|
|
735
|
+
return { id: customer.id, email: customer.email, firstName: customer.first_name, lastName: customer.last_name }
|
|
736
|
+
},
|
|
737
|
+
async me(token) {
|
|
738
|
+
try {
|
|
739
|
+
const { customer } = await api("/store/customers/me", { token })
|
|
740
|
+
return { id: customer.id, email: customer.email, firstName: customer.first_name, lastName: customer.last_name, phone: customer.phone }
|
|
741
|
+
} catch (e) {
|
|
742
|
+
if (e.status === 401) return null
|
|
743
|
+
throw e
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
async orders(token) {
|
|
747
|
+
const { orders = [] } = await api("/store/orders", { token })
|
|
748
|
+
return orders.map(mapOrder)
|
|
749
|
+
},
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const content = {
|
|
753
|
+
async listPosts(limit = 10) {
|
|
754
|
+
try {
|
|
755
|
+
const { posts = [] } = await api(`/tienda/posts?limit=${limit}`)
|
|
756
|
+
return posts.map(mapPost)
|
|
757
|
+
} catch {
|
|
758
|
+
return []
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
async getPost(handle) {
|
|
762
|
+
try {
|
|
763
|
+
const { posts = [] } = await api(`/tienda/posts?handle=${encodeURIComponent(handle)}`)
|
|
764
|
+
return posts[0] ? mapPost(posts[0]) : null
|
|
765
|
+
} catch {
|
|
766
|
+
return null
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
async listPages() {
|
|
770
|
+
try {
|
|
771
|
+
const { paginas = [] } = await api(`/tienda/paginas`)
|
|
772
|
+
return paginas.map(mapPagina)
|
|
773
|
+
} catch {
|
|
774
|
+
// Sin páginas el pie se pinta sin enlaces, que es peor que con ellos y
|
|
775
|
+
// muchísimo mejor que una tienda que no carga.
|
|
776
|
+
return []
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
async getPage(handle) {
|
|
780
|
+
try {
|
|
781
|
+
const { pagina } = await api(`/tienda/paginas/${encodeURIComponent(handle)}`)
|
|
782
|
+
return pagina ? mapPagina(pagina) : null
|
|
783
|
+
} catch {
|
|
784
|
+
// Un borrador o una que no existe dan 404, y eso es «no hay página»,
|
|
785
|
+
// no un error de la tienda.
|
|
786
|
+
return null
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const mapPost = (p) => ({
|
|
792
|
+
id: p.id,
|
|
793
|
+
handle: p.handle,
|
|
794
|
+
title: p.title,
|
|
795
|
+
excerpt: p.excerpt ?? null,
|
|
796
|
+
content: p.content ?? p.body ?? null,
|
|
797
|
+
image: p.image ?? p.thumbnail ?? null,
|
|
798
|
+
publishedAt: p.published_at ?? p.created_at ?? null,
|
|
799
|
+
tags: p.tags ?? [],
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
const mapPagina = (p) => ({
|
|
803
|
+
handle: p.handle,
|
|
804
|
+
title: p.titulo ?? p.title ?? p.handle,
|
|
805
|
+
content: p.contenido ?? p.content ?? null,
|
|
806
|
+
order: p.orden ?? p.order ?? 0,
|
|
807
|
+
seoTitle: p.seo_titulo ?? p.seoTitle ?? null,
|
|
808
|
+
seoDescription: p.seo_descripcion ?? p.seoDescription ?? null,
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
adapter: "pcreative",
|
|
813
|
+
listProducts,
|
|
814
|
+
getProduct,
|
|
815
|
+
getProductsByIds,
|
|
816
|
+
listCategories,
|
|
817
|
+
getCategory,
|
|
818
|
+
search,
|
|
819
|
+
getOrder,
|
|
820
|
+
cart,
|
|
821
|
+
checkout,
|
|
822
|
+
account,
|
|
823
|
+
content,
|
|
824
|
+
}
|
|
825
|
+
}
|