@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.
@@ -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
+ }