@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/src/index.d.ts ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Contrato de datos de commerce (v1.0).
3
+ *
4
+ * Un tema NO importa el SDK del backend. Consume estas entidades y llama a este
5
+ * cliente; el adaptador traduce. De ahí salen las dos propiedades que sostienen
6
+ * el producto: el mismo tema sirve para otro backend mañana, y el backend no
7
+ * queda casado con temas de un solo framework.
8
+ *
9
+ * Los importes son SIEMPRE decimales en unidades mayores (19.99, no 1999) y
10
+ * llevan su moneda al lado, para que ningún tema tenga que adivinar la escala.
11
+ */
12
+
13
+ // ── Entidades ─────────────────────────────────────────────────────────────
14
+
15
+ export interface Money {
16
+ /** Importe en unidades mayores: 19.99 €. */
17
+ amount: number
18
+ /** ISO 4217 en minúsculas: "eur". */
19
+ currency: string
20
+ /** true si `amount` ya lleva impuestos incluidos. */
21
+ taxIncluded?: boolean
22
+ }
23
+
24
+ export interface Image {
25
+ url: string
26
+ alt?: string
27
+ width?: number
28
+ height?: number
29
+ }
30
+
31
+ export interface OptionValue {
32
+ /** Nombre de la opción: "Talla", "Color". */
33
+ name: string
34
+ value: string
35
+ }
36
+
37
+ export interface Variant {
38
+ id: string
39
+ title: string
40
+ sku?: string
41
+ barcode?: string
42
+ price: Money
43
+ /** Precio anterior si hay rebaja. */
44
+ compareAt?: Money | null
45
+ /** null = el backend no lleva stock de esta variante. */
46
+ stock: number | null
47
+ available: boolean
48
+ options: OptionValue[]
49
+ weight?: number | null
50
+ image?: Image | null
51
+ }
52
+
53
+ export interface Product {
54
+ id: string
55
+ handle: string
56
+ title: string
57
+ subtitle?: string | null
58
+ description?: string | null
59
+ brand?: string | null
60
+ /**
61
+ * Quién vende este producto en un marketplace.
62
+ *
63
+ * AUSENTE significa que lo vende la propia tienda, que es el caso de
64
+ * cualquier instalación sin marketplace. Opcional a propósito: un tema escrito
65
+ * antes de que esto existiera sigue funcionando sin tocar una línea.
66
+ */
67
+ seller?: { id: string; name: string; slug: string } | null
68
+ images: Image[]
69
+ thumbnail?: string | null
70
+ /** Precio mínimo entre las variantes: el «desde X €» de la tarjeta. */
71
+ price: Money
72
+ variants: Variant[]
73
+ categories: CategoryRef[]
74
+ tags?: string[]
75
+ rating?: number | null
76
+ reviewCount?: number
77
+ available: boolean
78
+ metadata?: Record<string, unknown>
79
+ }
80
+
81
+ export interface CategoryRef {
82
+ id: string
83
+ handle: string
84
+ name: string
85
+ }
86
+
87
+ export interface Category extends CategoryRef {
88
+ description?: string | null
89
+ parentId?: string | null
90
+ children?: Category[]
91
+ image?: Image | null
92
+ productCount?: number
93
+ }
94
+
95
+ export interface Address {
96
+ firstName?: string
97
+ lastName?: string
98
+ company?: string
99
+ address1?: string
100
+ address2?: string
101
+ city?: string
102
+ province?: string
103
+ postalCode?: string
104
+ countryCode?: string
105
+ phone?: string
106
+ }
107
+
108
+ export interface LineItem {
109
+ id: string
110
+ productId: string
111
+ variantId: string
112
+ handle?: string
113
+ title: string
114
+ variantTitle?: string
115
+ thumbnail?: string | null
116
+ quantity: number
117
+ unitPrice: Money
118
+ total: Money
119
+ /**
120
+ * Es un CARGO, no mercancía: recargo por forma de pago y parecidos. No se
121
+ * envía y no tiene variante. Un tema que lo enseñe aparte debe excluirlo del
122
+ * subtotal, o lo cuenta dos veces.
123
+ */
124
+ isFee?: boolean
125
+ }
126
+
127
+ /** Un sitio del que sale mercancía: un paquete, un porte. */
128
+ export interface ShippingGroup {
129
+ /** Identificador opaco. El tema lo devuelve tal cual al elegir. */
130
+ grupo: string
131
+ /** Nombre del vendedor que lo manda. Vacío = lo manda la tienda. */
132
+ vendedor?: string | null
133
+ vendedor_id?: string | null
134
+ lineas: { id: string; titulo: string; cantidad: number }[]
135
+ /**
136
+ * Lo que puede elegir ESTE paquete.
137
+ *
138
+ * Va por grupo y no en común porque cada vendedor puede tener sus propias
139
+ * tarifas, y porque un precio calculado (por peso) depende de lo que lleva
140
+ * ese paquete, no el carrito entero.
141
+ *
142
+ * `precio` en unidades mayores; vacío = todavía no se sabe (tarifa calculada
143
+ * que el transportista no ha resuelto). El tema debe decirlo, no inventarlo.
144
+ */
145
+ opciones: { id: string; nombre: string; precio: number | null; calculado: boolean }[]
146
+ /** Lo ya elegido, para pintar la selección al recargar. */
147
+ option_id?: string | null
148
+ }
149
+
150
+ export interface ShippingGroups {
151
+ grupos: ShippingGroup[]
152
+ moneda: string
153
+ }
154
+
155
+ export interface EleccionEnvio {
156
+ grupo: string
157
+ optionId: string
158
+ }
159
+
160
+ export interface ShippingOption {
161
+ id: string
162
+ name: string
163
+ price: Money
164
+ /** El backend calcula el precio al elegirla (envío por peso, por ejemplo). */
165
+ priceCalculated?: boolean
166
+ description?: string | null
167
+ }
168
+
169
+ export interface PaymentMethod {
170
+ id: string
171
+ /** Identificador del proveedor: "transfer", "cod", "bizum", "card"… */
172
+ provider: string
173
+ name?: string
174
+ /**
175
+ * Recargo de esta forma de pago, en unidades mayores. Sin definir = ninguno.
176
+ *
177
+ * Es para ENSEÑARLO. Quien lo cobra es el backend al elegir la forma de pago,
178
+ * leyéndolo de la configuración de la tienda: si viniera del escaparate,
179
+ * cualquiera podría pedir un recargo de cero.
180
+ */
181
+ surcharge?: number
182
+ }
183
+
184
+ /**
185
+ * Cómo sigue el comprador para pagar.
186
+ *
187
+ * La misma forma que usa `@pcreative/payments-contract`, escrita otra vez a
188
+ * propósito: este paquete no depende de nada, y atarlo al de pagos obligaría a
189
+ * instalarlo para pintar un catálogo.
190
+ */
191
+ export type SiguientePago =
192
+ /** Mandar el navegador a la pasarela. */
193
+ | { tipo: "redirigir"; url: string; proveedor?: string }
194
+ /** Confirmar en el navegador con el SDK de la pasarela. `datos` es opaco. */
195
+ | {
196
+ tipo: "confirmar_en_cliente"
197
+ pasarela: string
198
+ datos: Record<string, unknown>
199
+ proveedor?: string
200
+ }
201
+
202
+ export interface Cart {
203
+ id: string
204
+ items: LineItem[]
205
+ subtotal: Money
206
+ shipping: Money
207
+ discount: Money
208
+ tax: Money
209
+ total: Money
210
+ itemCount: number
211
+ email?: string | null
212
+ shippingAddress?: Address | null
213
+ billingAddress?: Address | null
214
+ shippingMethods: { id: string; name: string; price: Money }[]
215
+ regionId?: string
216
+ promoCodes?: string[]
217
+ /**
218
+ * El paso que falta para pagar, si el método elegido lo pide. `null` con los
219
+ * métodos sin pasarela: ahí pagar es confirmar el pedido y ya está.
220
+ */
221
+ pago?: SiguientePago | null
222
+ }
223
+
224
+ export interface Order {
225
+ id: string
226
+ displayId?: number
227
+ email?: string
228
+ status: string
229
+ items: LineItem[]
230
+ subtotal: Money
231
+ shipping: Money
232
+ discount: Money
233
+ tax: Money
234
+ total: Money
235
+ shippingAddress?: Address | null
236
+ createdAt?: string
237
+ }
238
+
239
+ export interface Customer {
240
+ id: string
241
+ email: string
242
+ firstName?: string | null
243
+ lastName?: string | null
244
+ phone?: string | null
245
+ }
246
+
247
+ export interface Post {
248
+ id: string
249
+ handle: string
250
+ title: string
251
+ excerpt?: string | null
252
+ content?: string | null
253
+ image?: string | null
254
+ publishedAt?: string | null
255
+ tags?: string[]
256
+ }
257
+
258
+ /**
259
+ * Una página suelta del escaparate: «sobre nosotros», el aviso legal, los
260
+ * envíos. La escribe quien lleva la tienda desde el panel.
261
+ *
262
+ * 🔴 NO es una entrada de blog aunque se le parezca. Una entrada tiene fecha y
263
+ * se ordena por ella; una página tiene un ORDEN que decide una persona, porque
264
+ * lo que se pinta con ellas es el pie. Meterlas en el mismo tipo obligaría a
265
+ * inventarle una fecha al aviso legal.
266
+ */
267
+ export interface PaginaSuelta {
268
+ handle: string
269
+ title: string
270
+ /** Solo al pedir UNA. El listado no lo trae: el pie no lo necesita. */
271
+ content?: string | null
272
+ /** Lo decide quien la escribe. Es el orden del pie. */
273
+ order?: number
274
+ seoTitle?: string | null
275
+ seoDescription?: string | null
276
+ }
277
+
278
+ // ── Parámetros ────────────────────────────────────────────────────────────
279
+
280
+ export type SortProductos = "relevance" | "price_asc" | "price_desc" | "newest" | "rating"
281
+
282
+ export interface ListProductsParams {
283
+ q?: string
284
+ /** Handle de categoría. */
285
+ category?: string
286
+ categoryId?: string
287
+ ids?: string[]
288
+ tags?: string[]
289
+ limit?: number
290
+ offset?: number
291
+ sort?: SortProductos
292
+ }
293
+
294
+ export interface Page<T> {
295
+ items: T[]
296
+ /** Total de resultados, no de esta página. */
297
+ count: number
298
+ limit: number
299
+ offset: number
300
+ }
301
+
302
+ // ── El cliente ────────────────────────────────────────────────────────────
303
+
304
+ export interface CommerceClient {
305
+ /** Identificador del adaptador: "pcreative", "static"… */
306
+ readonly adapter: string
307
+
308
+ listProducts(params?: ListProductsParams): Promise<Page<Product>>
309
+ getProduct(handle: string): Promise<Product | null>
310
+ getProductsByIds(ids: string[]): Promise<Product[]>
311
+ listCategories(): Promise<Category[]>
312
+ getCategory(handle: string): Promise<Category | null>
313
+ search(q: string, limit?: number): Promise<Product[]>
314
+ /** Pedido por id, para la página de confirmación tras el checkout. */
315
+ getOrder(id: string): Promise<Order | null>
316
+
317
+ cart: {
318
+ get(id: string): Promise<Cart | null>
319
+ create(): Promise<Cart>
320
+ addItem(cartId: string, variantId: string, quantity: number): Promise<Cart>
321
+ updateItem(cartId: string, lineId: string, quantity: number): Promise<Cart>
322
+ removeItem(cartId: string, lineId: string): Promise<Cart>
323
+ applyPromo(cartId: string, code: string): Promise<Cart>
324
+ removePromo(cartId: string, code: string): Promise<Cart>
325
+ }
326
+
327
+ checkout: {
328
+ setEmail(cartId: string, email: string): Promise<Cart>
329
+ setAddresses(cartId: string, shipping: Address, billing?: Address): Promise<Cart>
330
+ listShippingOptions(cartId: string): Promise<ShippingOption[]>
331
+ setShippingMethod(cartId: string, optionId: string): Promise<Cart>
332
+ /**
333
+ * Opcional: de cuántos sitios sale el carrito.
334
+ *
335
+ * Una tienda normal devuelve un grupo y el tema puede ignorar todo esto y
336
+ * seguir con `listShippingOptions` / `setShippingMethod`. Un marketplace con
337
+ * vendedores que mandan lo suyo devuelve uno por sitio, y ahí un porte único
338
+ * sería cobrar un paquete y enviar tres.
339
+ *
340
+ * Un adaptador que no lo implemente no tiene marketplace: el tema lo
341
+ * comprueba con `typeof` y sigue por el camino de siempre.
342
+ */
343
+ listShippingGroups?(cartId: string): Promise<ShippingGroups>
344
+ /**
345
+ * Fija los portes de TODOS los grupos a la vez.
346
+ *
347
+ * El conjunto entero, siempre. No es capricho de la firma: en el backend,
348
+ * mandar un porte suelto borra los demás sin avisar de nada. Un método
349
+ * «cambia el porte del grupo X» dejaría el carrito con uno solo.
350
+ */
351
+ setShippingMethods?(cartId: string, elecciones: EleccionEnvio[]): Promise<void>
352
+ listPaymentMethods(cartId: string): Promise<PaymentMethod[]>
353
+ /**
354
+ * @param datos Lo que el escaparate quiera pasarle al proveedor de pago del
355
+ * backend. Se usa para la ruta de vuelta, que depende del idioma.
356
+ */
357
+ selectPaymentMethod(cartId: string, provider: string, datos?: Record<string, unknown>): Promise<Cart>
358
+ complete(cartId: string): Promise<{ order: Order } | { cart: Cart; error: string }>
359
+ }
360
+
361
+ /** Opcional: un adaptador puede no soportar cuentas de cliente. */
362
+ account?: {
363
+ login(email: string, password: string): Promise<{ token: string }>
364
+ register(datos: { email: string; password: string; firstName?: string; lastName?: string }): Promise<Customer>
365
+ me(token: string): Promise<Customer | null>
366
+ orders(token: string): Promise<Order[]>
367
+ }
368
+
369
+ /** Opcional: contenido editorial servido por el backend. */
370
+ content?: {
371
+ listPosts(limit?: number): Promise<Post[]>
372
+ getPost(handle: string): Promise<Post | null>
373
+ /**
374
+ * Las páginas publicadas, SIN su contenido: es lo que pinta el pie, y
375
+ * traerse el texto de ocho páginas para enseñar ocho enlaces es tráfico
376
+ * tirado en cada visita.
377
+ */
378
+ listPages?(): Promise<PaginaSuelta[]>
379
+ getPage?(handle: string): Promise<PaginaSuelta | null>
380
+ }
381
+ }
382
+
383
+ // ── API ───────────────────────────────────────────────────────────────────
384
+
385
+ export declare const CONTRACT_VERSION: string
386
+
387
+ /** Formatea un importe con la moneda que lleva dentro. */
388
+ export declare function formatMoney(money: Money, locale?: string): string
389
+
390
+ /** Suma importes comprobando que son de la misma moneda. */
391
+ export declare function sumMoney(...importes: Money[]): Money
392
+
393
+ /** Construye un Money, con 0 como valor por defecto. */
394
+ export declare function money(amount: number | null | undefined, currency: string, taxIncluded?: boolean): Money
395
+
396
+ /** Comprueba que un objeto implementa el contrato; devuelve lo que falta. */
397
+ export declare function assertCommerceClient(cliente: unknown): string[]
package/src/index.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @pcreative/commerce-contract — el contrato de datos que consume un tema.
3
+ *
4
+ * Aquí solo hay las utilidades comunes y el comprobador de conformidad: las
5
+ * entidades viven en `index.d.ts` (son forma, no código) y cada adaptador
6
+ * traduce lo suyo. Sin dependencias, para que valga igual en Next, Astro, Nuxt
7
+ * o un runtime edge.
8
+ */
9
+
10
+ export const CONTRACT_VERSION = "1.0"
11
+
12
+ /** Construye un importe. Los nulos del backend se vuelven 0, no NaN. */
13
+ export function money(amount, currency, taxIncluded) {
14
+ const m = { amount: Number(amount ?? 0) || 0, currency: String(currency ?? "eur").toLowerCase() }
15
+ if (taxIncluded !== undefined) m.taxIncluded = taxIncluded
16
+ return m
17
+ }
18
+
19
+ /**
20
+ * Formatea con `Intl`. Los importes del contrato son unidades mayores, así que
21
+ * no hay que dividir por 100 en ningún sitio — que es de donde salen la mitad
22
+ * de los errores de precio en las tiendas.
23
+ */
24
+ export function formatMoney(m, locale = "es-ES") {
25
+ if (!m) return ""
26
+ try {
27
+ return new Intl.NumberFormat(locale, {
28
+ style: "currency",
29
+ currency: (m.currency ?? "eur").toUpperCase(),
30
+ }).format(m.amount ?? 0)
31
+ } catch {
32
+ return `${(m.amount ?? 0).toFixed(2)} ${(m.currency ?? "").toUpperCase()}`
33
+ }
34
+ }
35
+
36
+ /** Suma importes. Mezclar monedas es un error, no algo que redondear. */
37
+ export function sumMoney(...importes) {
38
+ const validos = importes.filter(Boolean)
39
+ if (validos.length === 0) return money(0, "eur")
40
+ const currency = validos[0].currency
41
+ for (const m of validos) {
42
+ if (m.currency !== currency) {
43
+ throw new Error(`no se pueden sumar importes en ${currency} y en ${m.currency}`)
44
+ }
45
+ }
46
+ return money(
47
+ validos.reduce((n, m) => n + (m.amount ?? 0), 0),
48
+ currency,
49
+ validos[0].taxIncluded
50
+ )
51
+ }
52
+
53
+ const REQUERIDO = {
54
+ raiz: ["listProducts", "getProduct", "getProductsByIds", "listCategories", "getCategory", "search", "getOrder"],
55
+ cart: ["get", "create", "addItem", "updateItem", "removeItem"],
56
+ checkout: ["setEmail", "setAddresses", "listShippingOptions", "setShippingMethod", "listPaymentMethods", "selectPaymentMethod", "complete"],
57
+ }
58
+
59
+ /**
60
+ * Comprueba que un adaptador implementa el contrato. Devuelve la lista de lo
61
+ * que falta (vacía si cumple). Un tema puede llamarlo en desarrollo para
62
+ * enterarse al arrancar y no en mitad del checkout.
63
+ */
64
+ export function assertCommerceClient(cliente) {
65
+ const faltan = []
66
+ if (!cliente || typeof cliente !== "object") return ["el cliente no es un objeto"]
67
+ for (const m of REQUERIDO.raiz) {
68
+ if (typeof cliente[m] !== "function") faltan.push(m)
69
+ }
70
+ for (const grupo of ["cart", "checkout"]) {
71
+ if (!cliente[grupo] || typeof cliente[grupo] !== "object") {
72
+ faltan.push(grupo)
73
+ continue
74
+ }
75
+ for (const m of REQUERIDO[grupo]) {
76
+ if (typeof cliente[grupo][m] !== "function") faltan.push(`${grupo}.${m}`)
77
+ }
78
+ }
79
+ return faltan
80
+ }