@pcreative/commerce-contract 1.3.1 → 1.3.3

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/README.md CHANGED
@@ -44,6 +44,31 @@ al tema. Un carrito que ya no existe devuelve `null`, no una excepción.
44
44
  **`checkout.complete()` puede devolver `{ order }` o `{ cart, error }`** — las
45
45
  dos con HTTP 200. Si el cobro falla, el carrito vuelve intacto y con el motivo.
46
46
 
47
+ **El autor de una pieza y su soporte van juntos.** `getAuthor(handle)` trae la
48
+ ficha pública de quien vende en el mercado, con sus piezas y con lo que calcula
49
+ la plataforma: cuántas consultas resuelve y cuánto tarda en contestar. Lo que
50
+ todavía no tiene dato viene en `null` —nunca en cero— para que el tema pueda
51
+ enseñar «autor nuevo» en vez de un 0 % que espanta.
52
+
53
+ ```js
54
+ const autor = await commerce.getAuthor("estudio-demo")
55
+ autor.support?.resolvedPct // 80, o null si aún no hay tres valoraciones
56
+ autor.sales // 12, o null si todavía no ha vendido
57
+ ```
58
+
59
+ **Para escribir al autor hace falta la clave de licencia**: solo da soporte a
60
+ quien ha comprado. El hilo es privado y el correo del comprador viaja siempre en
61
+ el cuerpo, nunca en la dirección.
62
+
63
+ ```js
64
+ const { id, covered } = await commerce.support.open({
65
+ licenseKey, subject: "No instala", message: "Me da un error", email,
66
+ })
67
+ // covered === false: su soporte incluido ya caducó. Se avisa, no se bloquea.
68
+ await commerce.support.get(id, email)
69
+ await commerce.support.rate(id, email, true) // una sola pregunta al cerrar
70
+ ```
71
+
47
72
  ## Escribir otro adaptador
48
73
 
49
74
  Implementa la interfaz `CommerceClient` de [`src/index.d.ts`](src/index.d.ts) y
@@ -54,8 +79,8 @@ import { assertCommerceClient } from "@pcreative/commerce-contract"
54
79
  const faltan = assertCommerceClient(miCliente) // [] si cumple
55
80
  ```
56
81
 
57
- `account` y `content` son opcionales: un backend puede no tener cuentas de
58
- cliente ni blog.
82
+ `account`, `content`, `getAuthor` y `support` son opcionales: un backend puede
83
+ no tener cuentas de cliente, ni blog, ni mercado de autores.
59
84
 
60
85
  ## Tests
61
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pcreative/commerce-contract",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Contrato de datos de commerce: los tipos que consume un tema y los adaptadores que los resuelven. Sin dependencias ni SDK del backend.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -6,6 +6,26 @@ const ORDEN_DEL_CONTRATO = {
6
6
  price_desc: "precio_desc",
7
7
  }
8
8
 
9
+ const DE_QUIEN = { comprador: "buyer", autor: "author", tienda: "store" }
10
+
11
+ function aHilo(h) {
12
+ return {
13
+ id: h.id,
14
+ subject: h.asunto,
15
+ item: h.pieza,
16
+ status: h.estado,
17
+ covered: h.con_soporte !== false,
18
+ solved: h.resuelto ?? null,
19
+ messages: (h.mensajes ?? []).map((m) => ({
20
+ from: DE_QUIEN[m.de] ?? "store",
21
+ text: m.texto,
22
+ createdAt: m.creado_en,
23
+ })),
24
+ createdAt: h.creado_en,
25
+ updatedAt: h.movido_en,
26
+ }
27
+ }
28
+
9
29
  export function createCommerce({
10
30
  baseUrl,
11
31
  publishableKey,
@@ -108,11 +128,13 @@ export function createCommerce({
108
128
  tiers: v.tiers ?? [],
109
129
  digital: Boolean(v.digital),
110
130
  requiresShipping: v.requiresShipping ?? !v.digital,
131
+ code: v.code ?? null,
111
132
  bundle: v.bundle ?? null,
112
133
  bundleGroups: v.bundleGroups ?? null,
113
134
  measure: v.measure ?? null,
114
135
  unitPrice: v.unitPrice ?? null,
115
136
  })),
137
+ market: p.market ?? null,
116
138
  categories: p.categories ?? [],
117
139
  quantityRules: p.quantityRules ?? null,
118
140
  customization: p.customization ?? [],
@@ -203,6 +225,7 @@ export function createCommerce({
203
225
  if (params.q) qs.set("q", String(params.q))
204
226
  if (params.category) qs.set("categoria_handle", params.category)
205
227
  if (params.categoryId) qs.set("categoria", params.categoryId)
228
+ if (params.type) qs.set("tipo", params.type)
206
229
  if (params.ids?.length) qs.set("ids", params.ids.join(","))
207
230
  if (ORDEN_DEL_CONTRATO[params.sort]) qs.set("orden", ORDEN_DEL_CONTRATO[params.sort])
208
231
  const r = await api(`/tienda/productos?${qs}`)
@@ -571,6 +594,148 @@ export function createCommerce({
571
594
  throw e
572
595
  }
573
596
  },
597
+ async getLicenses(orderId) {
598
+ try {
599
+ const { licencias } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/licencias`)
600
+ return (licencias ?? []).map((l) => ({
601
+ key: l.key,
602
+ status: l.status,
603
+ title: l.title ?? null,
604
+ sites: Number(l.sites ?? 0),
605
+ limit: Number(l.limit ?? 1),
606
+ supportedUntil: l.supportedUntil ? String(l.supportedUntil).slice(0, 10) : null,
607
+ }))
608
+ } catch (e) {
609
+ if (e.status === 404) return []
610
+ throw e
611
+ }
612
+ },
613
+ async getCodes(orderId) {
614
+ try {
615
+ const { codigos } = await api(`/tienda/pedidos/${encodeURIComponent(orderId)}/codigos`)
616
+ return codigos ?? []
617
+ } catch (e) {
618
+ if (e.status === 404) return []
619
+ throw e
620
+ }
621
+ },
622
+ async revealCode(orderId, codeId) {
623
+ const { codigo } = await api(
624
+ `/tienda/pedidos/${encodeURIComponent(orderId)}/codigos/${encodeURIComponent(codeId)}/revelar`,
625
+ { method: "POST", body: {} },
626
+ )
627
+ return codigo ?? null
628
+ },
629
+ newsletter: {
630
+ async subscribe(email, consent = {}) {
631
+ await api("/tienda/boletin", {
632
+ method: "POST",
633
+ body: {
634
+ email,
635
+ texto: consent.text,
636
+ version: consent.version,
637
+ idioma: consent.locale,
638
+ origen: consent.source,
639
+ ip: consent.ip,
640
+ navegador: consent.userAgent,
641
+ },
642
+ })
643
+ return { status: "pendiente" }
644
+ },
645
+ async confirm(token) {
646
+ try {
647
+ await api("/tienda/boletin/confirmar", { method: "POST", body: { token } })
648
+ return true
649
+ } catch (e) {
650
+ if (e.status === 400) return false
651
+ throw e
652
+ }
653
+ },
654
+ async unsubscribe(token) {
655
+ await api("/tienda/boletin/baja", { method: "POST", body: { token } })
656
+ return true
657
+ },
658
+ },
659
+ async applyAsAuthor(a) {
660
+ const vende = [...new Set((a.sells ?? []).filter((x) => x === "tema" || x === "extension"))]
661
+ const r = await api("/tienda/autores/solicitudes", {
662
+ method: "POST",
663
+ body: {
664
+ nombre: a.name,
665
+ email: a.email,
666
+ portafolio: a.portfolio,
667
+ vende: vende.length === 2 ? ["los-dos"] : vende,
668
+ slug: a.handle,
669
+ pais: a.country,
670
+ web: a.website,
671
+ stacks: a.stacks,
672
+ experiencia: a.experience,
673
+ },
674
+ })
675
+ return { id: r.id }
676
+ },
677
+ async getAuthor(handle) {
678
+ try {
679
+ const { autor } = await api(`/tienda/autores/${encodeURIComponent(handle)}`)
680
+ if (!autor) return null
681
+ return {
682
+ handle: autor.handle,
683
+ name: autor.name,
684
+ bio: autor.bio ?? null,
685
+ website: autor.website ?? null,
686
+ country: autor.country ?? null,
687
+ links: autor.links ?? {},
688
+ logo: autor.logo ?? null,
689
+ since: autor.since,
690
+ items: (autor.items ?? []).map((i) => ({
691
+ handle: i.handle,
692
+ title: i.title,
693
+ type: i.type,
694
+ summary: i.summary ?? null,
695
+ cover: i.cover ?? null,
696
+ price: money(i.price?.amount ?? 0, i.price?.currency ?? "eur"),
697
+ })),
698
+ sales: autor.sales ?? null,
699
+ support: autor.soporte
700
+ ? {
701
+ resolvedPct: autor.soporte.porcentaje ?? null,
702
+ firstReplyHours: autor.soporte.horasPrimeraRespuesta ?? null,
703
+ ratings: autor.soporte.valorados ?? 0,
704
+ }
705
+ : null,
706
+ }
707
+ } catch (e) {
708
+ if (e.status === 404) return null
709
+ throw e
710
+ }
711
+ },
712
+ support: {
713
+ async open({ licenseKey, subject, message, email }) {
714
+ const r = await api("/tienda/soporte", {
715
+ method: "POST",
716
+ body: { clave: licenseKey, asunto: subject, texto: message, email },
717
+ })
718
+ return { id: r.id, covered: r.conSoporte !== false }
719
+ },
720
+ async get(id, email) {
721
+ try {
722
+ const { hilo } = await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email } })
723
+ return hilo ? aHilo(hilo) : null
724
+ } catch (e) {
725
+ if (e.status === 404 || e.status === 400) return null
726
+ throw e
727
+ }
728
+ },
729
+ async reply(id, email, message) {
730
+ await api(`/tienda/soporte/${encodeURIComponent(id)}`, { method: "POST", body: { email, texto: message } })
731
+ },
732
+ async rate(id, email, solved) {
733
+ await api(`/tienda/soporte/${encodeURIComponent(id)}`, {
734
+ method: "POST",
735
+ body: { email, resuelto: !!solved },
736
+ })
737
+ },
738
+ },
574
739
  cart,
575
740
  checkout,
576
741
  account,
package/src/index.d.ts CHANGED
@@ -53,6 +53,12 @@ export interface Variant {
53
53
  tiers?: PriceTier[]
54
54
  digital?: boolean
55
55
  requiresShipping?: boolean
56
+ /**
57
+ * Cuando la variante se vende como codigo: donde se canjea, para que region
58
+ * vale y en que idiomas viene. Se ensena ANTES de pagar; una clave de la
59
+ * region equivocada no sirve y acaba en devolucion.
60
+ */
61
+ code?: { platform: string | null; region: string | null; languages: string[]; instructions: string | null } | null
56
62
  bundle?: BundleComponent[] | null
57
63
  bundleGroups?: BundleGroup[] | null
58
64
  measure?: { quantity: number; unit: string } | null
@@ -86,6 +92,73 @@ export interface BundleComponent {
86
92
  image?: string | null
87
93
  }
88
94
 
95
+ /**
96
+ * Un codigo que se vende y se entrega: clave de juego, tarjeta, licencia.
97
+ * `code` solo llega cuando el comprador pide destaparlo; hasta entonces se
98
+ * ensena `hint` (los ultimos caracteres) y `revealed` dice si ya se destapo,
99
+ * que es lo que cuenta como entregado.
100
+ */
101
+ /**
102
+ * La licencia de una pieza del mercado comprada en un pedido. La clave es la
103
+ * credencial con la que el comprador activa el tema o la extensión en su sitio
104
+ * y con la que abre soporte con el autor: por eso se enseña entera.
105
+ */
106
+ export interface License {
107
+ key: string
108
+ status: "activa" | "caducada" | "anulada"
109
+ /** Nombre del producto al que da derecho. */
110
+ title: string | null
111
+ /** Sitios donde está activada ahora mismo. */
112
+ sites: number
113
+ /** Cuántos sitios admite. */
114
+ limit: number
115
+ /** Hasta cuándo incluye soporte y versiones nuevas (AAAA-MM-DD). null: sin fecha. */
116
+ supportedUntil: string | null
117
+ }
118
+
119
+ export interface ProductCode {
120
+ id: string
121
+ lineId: string
122
+ title: string
123
+ status: "libre" | "reservado" | "entregado" | "revocado" | "retirado"
124
+ hint: string
125
+ code: string | null
126
+ revealed: boolean
127
+ revealedAt: string | null
128
+ deliveredAt: string | null
129
+ reason: string | null
130
+ platform: string | null
131
+ region: string | null
132
+ languages: string[]
133
+ instructions: string | null
134
+ }
135
+
136
+ /**
137
+ * Lo que anade un producto que viene del mercado de temas y extensiones:
138
+ * version, cuando se actualizo, demo y changelog. Es lo que mira quien compra
139
+ * software y lo que un producto normal no tiene.
140
+ *
141
+ * En los listados llega un resumen: `screenshots` y `changelog` vienen vacíos
142
+ * y se rellenan en `getProduct`, que es donde se enseñan.
143
+ */
144
+ export interface MarketInfo {
145
+ type: "tema" | "extension"
146
+ author: { name: string; handle: string }
147
+ version: string | null
148
+ updatedAt: string | null
149
+ stack: string | null
150
+ niche: string[]
151
+ license: string | null
152
+ demoUrl: string | null
153
+ screenshots: string[]
154
+ changelog: { version: string; date: string | null; notes: string | null }[]
155
+ /**
156
+ * Meses de soporte del autor que incluye la compra (0: sin soporte). Las
157
+ * versiones nuevas no tienen fecha: le llegan al comprador siempre.
158
+ */
159
+ supportMonths?: number
160
+ }
161
+
89
162
  export interface Download {
90
163
  id: string
91
164
  lineId: string
@@ -149,6 +222,8 @@ export interface Product {
149
222
  /** Precio mínimo entre las variantes: el «desde X €» de la tarjeta. */
150
223
  price: Money
151
224
  variants: Variant[]
225
+ /** Solo cuando el producto es una pieza del mercado de temas y extensiones. */
226
+ market?: MarketInfo | null
152
227
  categories: CategoryRef[]
153
228
  tags?: string[]
154
229
  rating?: number | null
@@ -430,6 +505,11 @@ export interface ListProductsParams {
430
505
  categoryId?: string
431
506
  ids?: string[]
432
507
  tags?: string[]
508
+ /**
509
+ * Solo piezas del mercado de ese tipo. Sin él salen todos los productos,
510
+ * sean o no del mercado. Un adaptador sin mercado lo ignora.
511
+ */
512
+ type?: "tema" | "extension"
433
513
  limit?: number
434
514
  offset?: number
435
515
  sort?: SortProductos
@@ -445,6 +525,87 @@ export interface Page<T> {
445
525
 
446
526
  // ── El cliente ────────────────────────────────────────────────────────────
447
527
 
528
+
529
+ /** Una pieza del autor tal y como se lista en su ficha. */
530
+ export interface AuthorItem {
531
+ handle: string
532
+ title: string
533
+ type: "tema" | "extension"
534
+ summary: string | null
535
+ price: Money
536
+ cover: string | null
537
+ }
538
+
539
+ /**
540
+ * Lo que la plataforma calcula del soporte de un autor. Es lo que le da valor:
541
+ * no lo escribe él. Cuando todavía no hay datos suficientes viene en null, que
542
+ * se enseña como «autor nuevo» y nunca como un cero.
543
+ */
544
+ export interface AuthorSupport {
545
+ /** Porcentaje de consultas que sus compradores dieron por resueltas. */
546
+ resolvedPct: number | null
547
+ /** Mediana de horas hasta su primera respuesta. */
548
+ firstReplyHours: number | null
549
+ /** Cuántas valoraciones sostienen el porcentaje. */
550
+ ratings: number
551
+ }
552
+
553
+ /** La ficha pública de quien vende una pieza en el mercado. */
554
+ export interface Author {
555
+ handle: string
556
+ name: string
557
+ bio: string | null
558
+ website: string | null
559
+ /** Código de dos letras, en minúsculas. */
560
+ country: string | null
561
+ /** Redes y sitios del autor, siempre https. */
562
+ links: Record<string, string>
563
+ logo: string | null
564
+ /** Desde cuándo publica. */
565
+ since: string
566
+ items: AuthorItem[]
567
+ /** Solo cuando hay ventas: un cero espanta y no se enseña. */
568
+ sales: number | null
569
+ support: AuthorSupport | null
570
+ }
571
+
572
+ /** Lo que manda quien quiere vender en el mercado. Lo revisa una persona. */
573
+ export interface AuthorApplication {
574
+ /** Nombre con el que publicará. */
575
+ name: string
576
+ email: string
577
+ /** Enlace a algo que haya hecho: sin eso no hay nada que revisar. */
578
+ portfolio: string
579
+ sells: ("tema" | "extension")[]
580
+ /** Identificador público; si falta, sale del nombre. */
581
+ handle?: string
582
+ /** Código de dos letras. */
583
+ country?: string
584
+ website?: string
585
+ stacks?: string[]
586
+ experience?: string
587
+ }
588
+
589
+ export interface SupportMessage {
590
+ from: "buyer" | "author" | "store"
591
+ text: string
592
+ createdAt: string
593
+ }
594
+
595
+ export interface SupportThread {
596
+ id: string
597
+ subject: string
598
+ item: string
599
+ status: "abierto" | "contestado" | "esperando" | "cerrado"
600
+ /** Si su soporte incluido sigue vigente. Caducado no cierra la puerta: avisa. */
601
+ covered: boolean
602
+ /** Lo que contestó al cerrar. null mientras no se le ha preguntado. */
603
+ solved: boolean | null
604
+ messages: SupportMessage[]
605
+ createdAt: string
606
+ updatedAt: string
607
+ }
608
+
448
609
  export interface CommerceClient {
449
610
  /** Identificador del adaptador: "pcreative", "static"… */
450
611
  readonly adapter: string
@@ -461,10 +622,61 @@ export interface CommerceClient {
461
622
  subirFichero?(archivo: File): Promise<{ id: string; nombre: string; tipo: string; bytes: number; firma: string }>
462
623
  /** Opcional: las descargas de un pedido con contenido digital. */
463
624
  getDownloads?(orderId: string): Promise<Download[]>
625
+ /** Opcional: las licencias de las piezas del mercado compradas en un pedido. */
626
+ getLicenses?(orderId: string): Promise<License[]>
627
+ /** Los codigos de un pedido, tapados. */
628
+ getCodes?(orderId: string): Promise<ProductCode[]>
629
+ /** Destapa uno y devuelve el codigo entero. Queda apuntado quien y cuando. */
630
+ revealCode?(orderId: string, codeId: string): Promise<string>
464
631
  /** Opcional: estado del derecho de desistimiento de un pedido. */
465
632
  getWithdrawal?(orderId: string): Promise<WithdrawalState | null>
466
633
  /** Opcional: solicita el desistimiento de un pedido (botón obligatorio en la UE). */
467
634
  requestWithdrawal?(orderId: string, datos?: { lineas?: string[]; motivo?: string }): Promise<{ solicitud: string; aceptadas: number }>
635
+ /**
636
+ * Opcional: pedir vender en el mercado. Devuelve el id de la solicitud; la
637
+ * cuenta se crea cuando alguien la acepta. Si el correo ya pidió, falla con
638
+ * `err.status` 400 y el motivo en `err.detalle`.
639
+ */
640
+ applyAsAuthor?(application: AuthorApplication): Promise<{ id: string }>
641
+ /**
642
+ * Opcional: el boletín, con doble confirmación. `subscribe` NO apunta a nadie:
643
+ * manda un correo con un enlace, y solo al pulsarlo (`confirm`) queda dentro.
644
+ * Contesta siempre lo mismo para que no sirva para averiguar quién está.
645
+ */
646
+ newsletter?: {
647
+ subscribe(
648
+ email: string,
649
+ consent: {
650
+ /** El texto EXACTO que vio la persona junto a la casilla. Es la prueba. */
651
+ text: string
652
+ /** Versión de ese texto: cambia cuando cambia el texto. */
653
+ version: string
654
+ locale?: string
655
+ source?: string
656
+ ip?: string
657
+ userAgent?: string
658
+ },
659
+ ): Promise<{ status: "pendiente" }>
660
+ confirm(token: string): Promise<boolean>
661
+ unsubscribe(token: string): Promise<boolean>
662
+ }
663
+ /** Opcional: la ficha pública de un autor del mercado. */
664
+ getAuthor?(handle: string): Promise<Author | null>
665
+
666
+ /**
667
+ * Opcional: hablar con el autor de una pieza. Es privado y hace falta la
668
+ * clave de licencia, así que solo escribe quien ha comprado.
669
+ *
670
+ * El correo va en el cuerpo y nunca en la dirección: identifica a una
671
+ * persona y una dirección acaba en registros y en el historial del navegador.
672
+ */
673
+ support?: {
674
+ open(datos: { licenseKey: string; subject: string; message: string; email?: string }): Promise<{ id: string; covered: boolean }>
675
+ get(id: string, email: string): Promise<SupportThread | null>
676
+ reply(id: string, email: string, message: string): Promise<void>
677
+ /** Una sola pregunta al cerrar: si le sirvió. Alimenta el dato público del autor. */
678
+ rate(id: string, email: string, solved: boolean): Promise<void>
679
+ }
468
680
 
469
681
  cart: {
470
682
  get(id: string): Promise<Cart | null>