@cosmo-frameworks/paywall-react 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ Todos los cambios reseñables de este paquete se documentan aquí.
4
+
5
+ El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
+ y el versionado es [SemVer](https://semver.org/lang/es/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-08-21
11
+
12
+ Primera versión.
13
+
14
+ ### Añadido
15
+
16
+ - `<PaywallProvider>`: consulta el estado de licencia contra la API y lo
17
+ comparte con el árbol de la aplicación.
18
+ - `<PaywallGate>`: muestra avisos o suspende la aplicación según ese estado.
19
+ En `BLOCKED` **no renderiza los hijos**, para que tampoco corran sus efectos.
20
+ - `useLicenseStatus()`: acceso al estado en crudo, para integraciones a medida
21
+ sin usar `<PaywallGate>`.
22
+ - `clearLicenseCache()`: descarta el estado cacheado de una licencia.
23
+ - Cuatro estados de servicio: `OK`, `WARNING` (aviso descartable), `GRACE`
24
+ (aviso fijo con cuenta atrás) y `BLOCKED` (pantalla de suspensión).
25
+ - Escalada configurable por licencia desde el panel de administración, y
26
+ personalizable en el cliente vía `blockOn`.
27
+ - Pantallas por defecto con estilos en línea, sustituibles con `renderBlocked`,
28
+ `renderWarning` y `renderGrace`.
29
+
30
+ ### Comportamiento ante fallos
31
+
32
+ - **Fail-open**: sin respuesta del servidor se usa el último estado cacheado;
33
+ sin caché, la aplicación funciona con normalidad.
34
+ - Caché en `localStorage` con respaldo en memoria si el navegador lo prohíbe
35
+ (modo privado de Safari, cookies bloqueadas), y descarte a los 7 días.
36
+ - Lectura síncrona de la caché en el primer render: sin parpadeo al recargar.
37
+ - Reintentos con espera creciente, deduplicación de peticiones en vuelo y
38
+ recomprobación al volver a la pestaña y al recuperar conexión.
39
+
40
+ ### Compatibilidad
41
+
42
+ - React 18 y 19 como `peerDependency`.
43
+ - Vite, Create React App y Next.js App Router (incluye la directiva
44
+ `"use client"`).
45
+ - Seguro en renderizado de servidor: no consulta nada hasta hidratar.
46
+ - Se distribuye en ESM y CJS con tipos TypeScript.
47
+
48
+ ### Notas
49
+
50
+ - **Cero dependencias en tiempo de ejecución.** ~4 kB gzip.
51
+ - La license key no es un secreto: viaja en el bundle del navegador por diseño.
52
+ - Este paquete es la capa de aviso y de experiencia de usuario. El cumplimiento
53
+ real se consigue verificando en el servidor del cliente el token Ed25519 que
54
+ acompaña a cada respuesta.
55
+
56
+ [Unreleased]: https://github.com/cosmo-frameworks/paywall-react/compare/v0.1.0...HEAD
57
+ [0.1.0]: https://github.com/cosmo-frameworks/paywall-react/releases/tag/v0.1.0
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # @cosmo-frameworks/paywall-react
2
+
3
+ Control remoto del estado de servicio para aplicaciones React. La aplicación
4
+ consulta el estado de su licencia y muestra avisos, una cuenta atrás o una
5
+ pantalla de suspensión según el estado de los pagos.
6
+
7
+ - Sin dependencias (React es `peerDependency`). **~4 kB gzip.**
8
+ - Estilos en línea: no importa CSS ni choca con Tailwind, MUI ni nada tuyo.
9
+ - Compatible con Vite, CRA y Next.js App Router.
10
+ - **Fail-open:** si el servidor de licencias no responde, la aplicación sigue
11
+ funcionando con el último estado conocido.
12
+
13
+ ---
14
+
15
+ ## Instalación
16
+
17
+ ```bash
18
+ npm install @cosmo-frameworks/paywall-react
19
+ ```
20
+
21
+ Sin `.npmrc`, sin tokens y sin configuración de registro: es un paquete público
22
+ en npmjs.com.
23
+
24
+ ---
25
+
26
+ ## Uso
27
+
28
+ Envuelve la raíz de la aplicación, lo más arriba posible:
29
+
30
+ ```tsx
31
+ import { PaywallProvider, PaywallGate } from "@cosmo-frameworks/paywall-react";
32
+
33
+ export const App = () => (
34
+ <PaywallProvider
35
+ config={{
36
+ licenseKey: import.meta.env.VITE_LICENSE_KEY,
37
+ productName: "Panel de Acme",
38
+ contactEmail: "facturacion@acme.com",
39
+ }}
40
+ >
41
+ <PaywallGate>
42
+ <Router />
43
+ </PaywallGate>
44
+ </PaywallProvider>
45
+ );
46
+ ```
47
+
48
+ Eso es todo. Comportamiento según el estado que devuelva el servidor:
49
+
50
+ | Estado | Qué ve el usuario |
51
+ | --------- | -------------------------------------------------------------- |
52
+ | `OK` | La aplicación, sin nada añadido. |
53
+ | `WARNING` | La aplicación con un aviso descartable arriba. |
54
+ | `GRACE` | La aplicación con un aviso fijo y cuenta atrás hasta el corte. |
55
+ | `BLOCKED` | Pantalla de suspensión. **La aplicación no se renderiza.** |
56
+
57
+ ### Next.js (App Router)
58
+
59
+ El paquete ya incluye la directiva `"use client"`, así que se importa
60
+ directamente desde un layout:
61
+
62
+ ```tsx
63
+ // app/layout.tsx
64
+ import { PaywallProvider, PaywallGate } from "@cosmo-frameworks/paywall-react";
65
+
66
+ export default function RootLayout({ children }) {
67
+ return (
68
+ <html lang="es">
69
+ <body>
70
+ <PaywallProvider config={{ licenseKey: process.env.NEXT_PUBLIC_LICENSE_KEY! }}>
71
+ <PaywallGate>{children}</PaywallGate>
72
+ </PaywallProvider>
73
+ </body>
74
+ </html>
75
+ );
76
+ }
77
+ ```
78
+
79
+ > La license key **no es un secreto**: viaja en el bundle del navegador por
80
+ > diseño. Usa `VITE_`/`NEXT_PUBLIC_` sin problema. Lo que la key permite es
81
+ > únicamente consultar el estado de servicio; no da acceso a ningún dato.
82
+
83
+ ---
84
+
85
+ ## Configuración
86
+
87
+ ```ts
88
+ interface PaywallConfigI {
89
+ licenseKey: string; // requerido
90
+ apiUrl?: string; // por defecto https://api.clients.ridel.dev
91
+ refreshIntervalMs?: number; // por defecto 15 min (o lo que pida el servidor)
92
+ blockOn?: LicenseStateT[]; // por defecto ["BLOCKED"]
93
+ failOpen?: boolean; // por defecto true
94
+ maxCacheAgeMs?: number; // por defecto 7 días
95
+ storage?: "local" | "memory" | "none";
96
+ contactEmail?: string;
97
+ productName?: string;
98
+ debug?: boolean;
99
+ onStatusChange?: (status: LicenseStatusI) => void;
100
+ fetchImpl?: typeof fetch;
101
+ }
102
+ ```
103
+
104
+ ### Personalizar las pantallas
105
+
106
+ ```tsx
107
+ <PaywallGate
108
+ renderBlocked={(status) => <MiPantallaDeCorte mensaje={status.message} />}
109
+ renderWarning={(status) => <MiBanner mensaje={status.message} />}
110
+ showBanners
111
+ >
112
+ <App />
113
+ </PaywallGate>
114
+ ```
115
+
116
+ ### Modo headless
117
+
118
+ Si prefieres controlarlo todo tú, usa el hook y olvídate de `<PaywallGate>`:
119
+
120
+ ```tsx
121
+ import { useLicenseStatus } from "@cosmo-frameworks/paywall-react";
122
+
123
+ const Cabecera = () => {
124
+ const { state, message, graceUntil, contactUrl, refresh } = useLicenseStatus();
125
+
126
+ if (state === "OK") return null;
127
+ return <aside>{message} <a href={contactUrl ?? "#"}>Regularizar</a></aside>;
128
+ };
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Comportamiento ante fallos
134
+
135
+ | Situación | Qué ocurre |
136
+ | ---------------------------------- | --------------------------------------------------- |
137
+ | El servidor no responde | Se usa el último estado cacheado. |
138
+ | No responde y no hay caché | **Se deja pasar.** La aplicación funciona. |
139
+ | La caché tiene más de 7 días | Se descarta y se deja pasar. |
140
+ | `localStorage` bloqueado | Se cae a memoria; la caché dura lo que la pestaña. |
141
+ | Licencia desconocida o revocada | Revocada bloquea; desconocida deja pasar y avisa por consola. |
142
+ | Renderizado en servidor (SSR) | No se consulta nada; se resuelve al hidratar. |
143
+
144
+ Las peticiones concurrentes se deduplican, hay reintentos con espera creciente
145
+ tras un fallo, y se recomprueba al volver a la pestaña y al recuperar conexión.
146
+
147
+ ---
148
+
149
+ ## Preguntas frecuentes
150
+
151
+ **¿Puedo saltarme el bloqueo desde las herramientas de desarrollo?**
152
+ En el navegador, sí: es JavaScript en tu máquina. Este paquete es la capa de
153
+ aviso y de experiencia de usuario. El cumplimiento real se hace verificando en
154
+ tu propio servidor el token firmado (Ed25519) que acompaña a cada respuesta.
155
+
156
+ **¿Qué datos se envían?**
157
+ Solo la license key. La respuesta no incluye importes ni datos personales salvo
158
+ que la licencia lo autorice explícitamente.
159
+
160
+ **¿Y si borro `localStorage`?**
161
+ Se dispara una consulta nueva, que es más estricta, no menos.
162
+
163
+ ---
164
+
165
+ ## Desarrollo
166
+
167
+ ```bash
168
+ npm install
169
+ npm test # Vitest
170
+ npm run build # ESM + CJS + tipos
171
+ npm pack # tarball para probar la integración en otro proyecto
172
+ ```
173
+
174
+ ## Publicación
175
+
176
+ El código vive en `cosmo-frameworks/paywall-react` (repositorio privado) y el
177
+ paquete se publica en **npmjs.com** bajo el scope `@cosmo-frameworks`.
178
+
179
+ Se eligió npmjs y no GitHub Packages porque este último exige un token de
180
+ acceso **incluso para paquetes públicos**, lo que obligaría a cada cliente a
181
+ configurar un `.npmrc` con credenciales y a resolver la autenticación dentro de
182
+ su Dockerfile. En npmjs un paquete público se instala sin nada.
183
+
184
+ La publicación usa **trusted publishing (OIDC)**: el workflow obtiene una
185
+ credencial efímera de npm y no hay ningún secreto guardado en el repositorio.
186
+ Se eligió así porque los tokens de npm ya no son una opción sostenible — los
187
+ classic fueron revocados en diciembre de 2025, los granulares de escritura
188
+ caducan a los 90 días, y los de bypass-2FA pierden la capacidad de publicar en
189
+ enero de 2027.
190
+
191
+ Requisitos, una sola vez:
192
+
193
+ - La organización `cosmo-frameworks` creada en npmjs.com (gratis para paquetes
194
+ públicos).
195
+ - El **trusted publisher** configurado en los ajustes del paquete, apuntando a
196
+ este repositorio y a `.github/workflows/publish.yml`.
197
+ - El primer `npm publish` hecho a mano desde tu máquina, porque el trusted
198
+ publisher se configura sobre un paquete que ya existe.
199
+
200
+ La procedencia firmada se genera sola en este modo: no hace falta
201
+ `--provenance`.
202
+
203
+ > **No ejecutes `git init` aquí mientras la carpeta siga dentro del monorepo.**
204
+ > Un repositorio anidado hace que el monorepo trate esta carpeta como repo
205
+ > externo y deje de versionar su contenido **en silencio**: los commits pasan
206
+ > sin error y el paquete no viaja. Si alguna herramienta gráfica (GitKraken y
207
+ > similares crean un `.git` al detectar carpetas nuevas) lo ha creado, bórralo
208
+ > con `rm -rf packages/paywall-react/.git` antes de commitear.
209
+
210
+ El **repositorio es privado** y el **paquete es público**: el `src/` y el
211
+ historial no se ven, y solo se distribuye lo que declara `files` — el bundle de
212
+ `dist/` y este README, sin sourcemaps, que embeberían el TypeScript original.
213
+
214
+ Primera publicación, ya con la carpeta **fuera** del monorepo:
215
+
216
+ ```bash
217
+ cd paywall-react
218
+ git init && git add . && git commit -m "chore: primera versión"
219
+ git remote add origin git@github.com:cosmo-frameworks/paywall-react.git
220
+ git push -u origin main
221
+ git tag v0.1.0 && git push --tags # dispara el workflow
222
+ ```
223
+
224
+ A partir de ahí, ese repositorio es la fuente de verdad y esta carpeta puede
225
+ eliminarse del monorepo.
@@ -0,0 +1,14 @@
1
+ export declare const DEFAULT_API_URL = "https://api.clients.ridel.dev";
2
+ export declare const STATUS_PATH = "/api/public/license/v1/status";
3
+ /** Cada cuánto se recomprueba si el servidor no dice otra cosa. */
4
+ export declare const DEFAULT_REFRESH_INTERVAL_MS: number;
5
+ /**
6
+ * Pasada esta antigüedad la caché se descarta y, sin red, se deja pasar.
7
+ * Es el límite de cuánto tiempo puede un cliente seguir bloqueado (o
8
+ * desbloqueado) sin contacto con el servidor.
9
+ */
10
+ export declare const DEFAULT_MAX_CACHE_AGE_MS: number;
11
+ export declare const CACHE_PREFIX = "cpl:state:";
12
+ export declare const REQUEST_TIMEOUT_MS = 10000;
13
+ export declare const BACKOFF_MIN_MS = 1000;
14
+ export declare const BACKOFF_MAX_MS = 30000;
@@ -0,0 +1,10 @@
1
+ import { CachedEntryI, LicensePayloadI, PaywallConfigI } from '../types';
2
+ type CacheOptionsT = Pick<PaywallConfigI, "storage" | "maxCacheAgeMs">;
3
+ export declare const writeCache: (licenseKey: string, payload: LicensePayloadI, options: CacheOptionsT) => void;
4
+ export declare const readCache: (licenseKey: string, options: CacheOptionsT) => CachedEntryI | null;
5
+ /**
6
+ * Borra el estado cacheado de una licencia. Útil al cerrar sesión o para
7
+ * forzar una comprobación limpia; no desbloquea nada, solo provoca un fetch.
8
+ */
9
+ export declare const clearLicenseCache: (licenseKey: string) => void;
10
+ export {};
@@ -0,0 +1,21 @@
1
+ import { LicensePayloadI, LicenseSourceT, PaywallConfigI } from '../types';
2
+ export interface LicenseFetchResultI {
3
+ payload: LicensePayloadI | null;
4
+ source: LicenseSourceT;
5
+ /** Antigüedad de la caché usada; irrelevante cuando `source` es `network`. */
6
+ isStale: boolean;
7
+ error: Error | null;
8
+ }
9
+ type FetchOptionsT = Pick<PaywallConfigI, "licenseKey" | "apiUrl" | "storage" | "maxCacheAgeMs" | "fetchImpl"> & {
10
+ signal?: AbortSignal;
11
+ };
12
+ /**
13
+ * Recupera el estado de licencia y decide qué mostrar si el servidor falla.
14
+ *
15
+ * Fail-open por diseño: si no hay respuesta se usa el último estado conocido y,
16
+ * a falta de él, se deja pasar. Una caída del servidor de licencias nunca puede
17
+ * tumbar la aplicación de un cliente que sí paga; el precio es que un moroso
18
+ * gana tiempo mientras el servidor esté caído.
19
+ */
20
+ export declare const fetchLicenseStatus: (options: FetchOptionsT) => Promise<LicenseFetchResultI>;
21
+ export {};
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Guarda de SSR. El paquete se importa desde Next.js App Router, donde el
3
+ * primer render ocurre en el servidor y `window` no existe.
4
+ */
5
+ export declare const isBrowser: () => boolean;
@@ -0,0 +1,10 @@
1
+ import { GateDecisionT, LicenseStateT } from '../types';
2
+ /**
3
+ * Traduce el estado del servidor a lo que hace el `<PaywallGate>`.
4
+ *
5
+ * Puro y sin dependencias para poder cubrir la matriz completa de estados en
6
+ * tests: es la única función capaz de tapar la aplicación de un cliente.
7
+ */
8
+ export declare const resolveGateDecision: (state: LicenseStateT, config: {
9
+ blockOn?: LicenseStateT[];
10
+ }) => GateDecisionT;
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use client";Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("react"),t=require("react/jsx-runtime");var n=`/api/public/license/v1/status`,r=`cpl:state:`,i=1e4,a=1e3,o=3e4,s=()=>typeof window<`u`&&window.document!==void 0,c=new Map,l=e=>`${r}${e.slice(0,12)}`,u=e=>(e.storage??`local`)===`local`&&s(),d=e=>{if(typeof e!=`object`||!e)return!1;let t=e;return t.version!==1||typeof t.receivedAt!=`number`?!1:typeof t.payload?.status==`string`},f=(e,t,n)=>{if((n.storage??`local`)===`none`)return;let r={version:1,payload:t,receivedAt:Date.now()},i=l(e);if(c.set(i,r),u(n))try{window.localStorage.setItem(i,JSON.stringify(r))}catch{}},p=(e,t)=>{if((t.storage??`local`)===`none`)return null;let n=l(e),r=c.get(n)??null;if(!r&&u(t))try{let e=window.localStorage.getItem(n);if(e){let t=JSON.parse(e);d(t)&&(r=t)}}catch{}if(!r)return null;let i=t.maxCacheAgeMs??6048e5;return Date.now()-r.receivedAt>i?(m(e),null):r},m=e=>{let t=l(e);if(c.delete(t),s())try{window.localStorage.removeItem(t)}catch{}},h=1,g=e=>{if(typeof e!=`object`||!e)return!1;let t=e;return t.v===h&&typeof t.status==`string`&&typeof t.licenseId==`string`},_=async e=>{let{licenseKey:t}=e;if(!t)return v(e,Error(`[paywall] Falta licenseKey en la configuración.`));let r=e.fetchImpl??globalThis.fetch;if(typeof r!=`function`)return v(e,Error(`[paywall] No hay implementación de fetch disponible.`));let a=`${(e.apiUrl??`https://api.clients.ridel.dev`).replace(/\/+$/,``)}${n}?key=${encodeURIComponent(t)}&t=${Date.now()}`,o=new AbortController,s=setTimeout(()=>o.abort(),i);try{let n=await r(a,{method:`GET`,credentials:`omit`,signal:e.signal??o.signal}),i=await n.json();if(!n.ok){let t=i?.error??`HTTP ${n.status}`;return v(e,Error(`[paywall] El servidor rechazó la licencia: ${t}`))}return g(i)?(f(t,i,e),{payload:i,source:`network`,isStale:!1,error:null}):v(e,Error(`[paywall] Respuesta con un formato que esta versión del SDK no entiende. Actualiza el paquete.`))}catch(t){return v(e,y(t))}finally{clearTimeout(s)}},v=(e,t)=>{let n=e.licenseKey?p(e.licenseKey,e):null;return n?{payload:n.payload,source:`cache`,isStale:Date.parse(n.payload.expiresAt)<Date.now(),error:t}:{payload:null,source:`none`,isStale:!1,error:t}},y=e=>e instanceof Error?e:Error(`[paywall] Fallo al consultar la licencia: ${String(e)}`),b=(0,e.createContext)(null),x=e=>e?new Date(e):null,S=new Map,C=(0,e.memo)(({config:n,children:r})=>{let[i,c]=(0,e.useState)(()=>{let e=s()?p(n.licenseKey,n):null;return{payload:e?.payload??null,source:e?`cache`:`none`,isStale:e?Date.parse(e.payload.expiresAt)<Date.now():!1,isLoading:!0,error:null}}),l=(0,e.useRef)(0),u=(0,e.useRef)(null),d=(0,e.useRef)(n.onStatusChange);d.current=n.onStatusChange;let f=(0,e.useCallback)(async()=>{if(!s())return;u.current?.abort();let e=new AbortController;u.current=e;let t=`${n.apiUrl??``}|${n.licenseKey}`,r=S.get(t),i=r??_({licenseKey:n.licenseKey,apiUrl:n.apiUrl,storage:n.storage,maxCacheAgeMs:n.maxCacheAgeMs,fetchImpl:n.fetchImpl,signal:e.signal});r||S.set(t,i);try{let t=await i;if(e.signal.aborted)return;l.current=t.source===`network`?0:l.current+1,c({payload:t.payload,source:t.source,isStale:t.isStale,isLoading:!1,error:t.error}),n.debug&&console.info(`[paywall]`,t.source,t.payload?.status,t.error)}finally{r||S.delete(t)}},[n.apiUrl,n.licenseKey,n.storage,n.maxCacheAgeMs,n.fetchImpl,n.debug]);(0,e.useEffect)(()=>{if(!s())return;f();let e=i.payload?.refreshAfterSeconds,t=n.refreshIntervalMs??9e5,r=Math.min(t,e?e*1e3:t),c=l.current>0?Math.min(o,a*2**Math.min(l.current,5)):r,d=setInterval(()=>void f(),c);return()=>{clearInterval(d),u.current?.abort()}},[f,n.refreshIntervalMs,i.payload]),(0,e.useEffect)(()=>{if(!s())return;let e=()=>{document.visibilityState===`visible`&&f()};return document.addEventListener(`visibilitychange`,e),window.addEventListener(`online`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`online`,e)}},[f]);let m=(0,e.useMemo)(()=>{let e=i.payload;return{state:e?.status??`OK`,reason:e?.reason??`NONE`,isBlocked:e?.status===`BLOCKED`,isLoading:i.isLoading,isStale:i.isStale,source:i.source,graceUntil:x(e?.graceUntil),nextTransitionAt:x(e?.nextTransitionAt),message:e?.message??null,contactUrl:e?.contactUrl??null,payload:e,error:i.error,refresh:()=>void f()}},[i,f]);(0,e.useEffect)(()=>{d.current?.(m)},[m]);let h=(0,e.useMemo)(()=>({status:m,config:n}),[m,n]);return(0,t.jsx)(b.Provider,{value:h,children:r})});C.displayName=`PaywallProvider`;var w=[`BLOCKED`],T={OK:`allow`,WARNING:`warn`,GRACE:`grace`,BLOCKED:`grace`},E=(e,t)=>{let n=T[e]??`allow`;return e===`OK`?`allow`:(t.blockOn??w).includes(e)?`block`:n},D=`system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif`,O={position:`fixed`,inset:0,zIndex:2147483647,display:`flex`,alignItems:`center`,justifyContent:`center`,padding:`24px`,background:`rgba(9, 9, 11, 0.92)`,backdropFilter:`blur(6px)`,fontFamily:D,color:`#fafafa`,overflowY:`auto`},k={width:`100%`,maxWidth:`440px`,background:`#18181b`,border:`1px solid #27272a`,borderRadius:`16px`,padding:`32px`,boxShadow:`0 24px 48px rgba(0, 0, 0, 0.45)`,textAlign:`center`},A={display:`inline-flex`,alignItems:`center`,justifyContent:`center`,width:`48px`,height:`48px`,borderRadius:`50%`,background:`rgba(239, 68, 68, 0.14)`,color:`#f87171`,fontSize:`24px`,marginBottom:`20px`},j={margin:`0 0 12px`,fontSize:`20px`,fontWeight:600,lineHeight:1.3},M={margin:`0 0 24px`,fontSize:`15px`,lineHeight:1.55,color:`#a1a1aa`},N={display:`inline-block`,padding:`11px 22px`,borderRadius:`10px`,background:`#fafafa`,color:`#18181b`,fontSize:`14px`,fontWeight:600,textDecoration:`none`},P={margin:`20px 0 0`,fontSize:`12px`,color:`#71717a`},F={position:`sticky`,top:0,zIndex:2147483e3,display:`flex`,alignItems:`center`,gap:`12px`,padding:`10px 16px`,fontFamily:D,fontSize:`14px`,lineHeight:1.4,boxSizing:`border-box`},I={...F,background:`#fef3c7`,color:`#78350f`,borderBottom:`1px solid #fcd34d`},L={...F,background:`#fee2e2`,color:`#7f1d1d`,borderBottom:`1px solid #fca5a5`},R={color:`inherit`,fontWeight:600,textDecoration:`underline`,whiteSpace:`nowrap`},z={marginLeft:`auto`,padding:`2px 8px`,border:`none`,borderRadius:`6px`,background:`transparent`,color:`inherit`,fontSize:`18px`,lineHeight:1,cursor:`pointer`},B={flex:1,minWidth:0},V=`Este servicio está temporalmente suspendido. Ponte en contacto con el proveedor para restablecerlo.`,H=(0,e.memo)(({status:n,config:r})=>{let i=(0,e.useMemo)(()=>r.productName?`${r.productName} no está disponible`:`Servicio no disponible`,[r.productName]);return(0,t.jsx)(`div`,{style:O,role:`alertdialog`,"aria-modal":`true`,children:(0,t.jsxs)(`div`,{style:k,children:[(0,t.jsx)(`div`,{style:A,"aria-hidden":`true`,children:`⚠`}),(0,t.jsx)(`h1`,{style:j,children:i}),(0,t.jsx)(`p`,{style:M,children:n.message??V}),n.contactUrl?(0,t.jsx)(`a`,{style:N,href:n.contactUrl,target:`_blank`,rel:`noreferrer noopener`,children:`Resolver ahora`}):null,r.contactEmail?(0,t.jsxs)(`p`,{style:P,children:[`¿Dudas? Escribe a`,` `,(0,t.jsx)(`a`,{href:`mailto:${r.contactEmail}`,style:R,children:r.contactEmail})]}):null]})})});H.displayName=`BlockedOverlay`;var U=e=>{if(!e)return null;let t=e.getTime()-Date.now();if(t<=0)return null;let n=Math.floor(t/864e5);if(n>=1)return n===1?`1 día`:`${n} días`;let r=Math.max(1,Math.floor(t/36e5));return r===1?`1 hora`:`${r} horas`},W=(0,e.memo)(({status:n,variant:r})=>{let[i,a]=(0,e.useState)(!1),o=(0,e.useMemo)(()=>U(n.graceUntil),[n.graceUntil]);if(i)return null;let s=r===`grace`?L:I;return(0,t.jsxs)(`div`,{style:s,role:`status`,children:[(0,t.jsxs)(`span`,{style:B,children:[n.message,o?(0,t.jsxs)(`strong`,{children:[` El servicio se suspenderá en `,o,`.`]}):null]}),n.contactUrl?(0,t.jsx)(`a`,{style:R,href:n.contactUrl,target:`_blank`,rel:`noreferrer noopener`,children:`Resolver`}):null,r===`warning`?(0,t.jsx)(`button`,{type:`button`,style:z,onClick:()=>a(!0),"aria-label":`Descartar aviso`,children:`×`}):null]})});W.displayName=`StatusBanner`;var G=(0,e.memo)(({children:n,renderBlocked:r,renderWarning:i,renderGrace:a,showBanners:o=!0})=>{let s=(0,e.useContext)(b);if(!s)throw Error(`[paywall] <PaywallGate> debe usarse dentro de <PaywallProvider>.`);let{status:c,config:l}=s,u=(0,e.useMemo)(()=>E(c.state,l),[c.state,l]);if(u===`block`)return(0,t.jsx)(t.Fragment,{children:r?r(c):(0,t.jsx)(H,{status:c,config:l})});let d=o?u===`warn`?i?i(c):(0,t.jsx)(W,{status:c,variant:`warning`}):u===`grace`?a?a(c):(0,t.jsx)(W,{status:c,variant:`grace`}):null:null;return(0,t.jsxs)(t.Fragment,{children:[d,n]})});G.displayName=`PaywallGate`;var K=()=>{let t=(0,e.useContext)(b);if(!t)throw Error(`[paywall] useLicenseStatus debe usarse dentro de <PaywallProvider>.`);return t.status};exports.PaywallGate=G,exports.PaywallProvider=C,exports.clearLicenseCache=m,exports.useLicenseStatus=K;
@@ -0,0 +1,6 @@
1
+ export { PaywallProvider } from './react/PaywallProvider';
2
+ export { PaywallGate } from './react/PaywallGate';
3
+ export { useLicenseStatus } from './react/useLicenseStatus';
4
+ export { clearLicenseCache } from './core/cache';
5
+ export type { PaywallConfigI, PaywallGatePropsI, LicenseStatusI, LicensePayloadI, LicenseStateT, LicenseReasonT, LicenseSourceT, GateDecisionT, } from './types';
6
+ export type { PaywallProviderPropsI } from './react/PaywallProvider';
package/dist/index.js ADDED
@@ -0,0 +1,400 @@
1
+ "use client";
2
+ import { createContext as e, memo as t, useCallback as n, useContext as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
3
+ import { Fragment as c, jsx as l, jsxs as u } from "react/jsx-runtime";
4
+ var d = "/api/public/license/v1/status", f = "cpl:state:", p = 1e4, m = 1e3, h = 3e4, g = () => typeof window < "u" && window.document !== void 0, _ = /* @__PURE__ */ new Map(), v = (e) => `${f}${e.slice(0, 12)}`, y = (e) => (e.storage ?? "local") === "local" && g(), b = (e) => {
5
+ if (typeof e != "object" || !e) return !1;
6
+ let t = e;
7
+ return t.version !== 1 || typeof t.receivedAt != "number" ? !1 : typeof t.payload?.status == "string";
8
+ }, x = (e, t, n) => {
9
+ if ((n.storage ?? "local") === "none") return;
10
+ let r = {
11
+ version: 1,
12
+ payload: t,
13
+ receivedAt: Date.now()
14
+ }, i = v(e);
15
+ if (_.set(i, r), y(n)) try {
16
+ window.localStorage.setItem(i, JSON.stringify(r));
17
+ } catch {}
18
+ }, S = (e, t) => {
19
+ if ((t.storage ?? "local") === "none") return null;
20
+ let n = v(e), r = _.get(n) ?? null;
21
+ if (!r && y(t)) try {
22
+ let e = window.localStorage.getItem(n);
23
+ if (e) {
24
+ let t = JSON.parse(e);
25
+ b(t) && (r = t);
26
+ }
27
+ } catch {}
28
+ if (!r) return null;
29
+ let i = t.maxCacheAgeMs ?? 6048e5;
30
+ return Date.now() - r.receivedAt > i ? (C(e), null) : r;
31
+ }, C = (e) => {
32
+ let t = v(e);
33
+ if (_.delete(t), g()) try {
34
+ window.localStorage.removeItem(t);
35
+ } catch {}
36
+ }, w = 1, ee = (e) => {
37
+ if (typeof e != "object" || !e) return !1;
38
+ let t = e;
39
+ return t.v === w && typeof t.status == "string" && typeof t.licenseId == "string";
40
+ }, T = async (e) => {
41
+ let { licenseKey: t } = e;
42
+ if (!t) return E(e, /* @__PURE__ */ Error("[paywall] Falta licenseKey en la configuración."));
43
+ let n = e.fetchImpl ?? globalThis.fetch;
44
+ if (typeof n != "function") return E(e, /* @__PURE__ */ Error("[paywall] No hay implementación de fetch disponible."));
45
+ let r = `${(e.apiUrl ?? "https://api.clients.ridel.dev").replace(/\/+$/, "")}${d}?key=${encodeURIComponent(t)}&t=${Date.now()}`, i = new AbortController(), a = setTimeout(() => i.abort(), p);
46
+ try {
47
+ let a = await n(r, {
48
+ method: "GET",
49
+ credentials: "omit",
50
+ signal: e.signal ?? i.signal
51
+ }), o = await a.json();
52
+ if (!a.ok) {
53
+ let t = o?.error ?? `HTTP ${a.status}`;
54
+ return E(e, /* @__PURE__ */ Error(`[paywall] El servidor rechazó la licencia: ${t}`));
55
+ }
56
+ return ee(o) ? (x(t, o, e), {
57
+ payload: o,
58
+ source: "network",
59
+ isStale: !1,
60
+ error: null
61
+ }) : E(e, /* @__PURE__ */ Error("[paywall] Respuesta con un formato que esta versión del SDK no entiende. Actualiza el paquete."));
62
+ } catch (t) {
63
+ return E(e, D(t));
64
+ } finally {
65
+ clearTimeout(a);
66
+ }
67
+ }, E = (e, t) => {
68
+ let n = e.licenseKey ? S(e.licenseKey, e) : null;
69
+ return n ? {
70
+ payload: n.payload,
71
+ source: "cache",
72
+ isStale: Date.parse(n.payload.expiresAt) < Date.now(),
73
+ error: t
74
+ } : {
75
+ payload: null,
76
+ source: "none",
77
+ isStale: !1,
78
+ error: t
79
+ };
80
+ }, D = (e) => e instanceof Error ? e : /* @__PURE__ */ Error(`[paywall] Fallo al consultar la licencia: ${String(e)}`), O = e(null), k = (e) => e ? new Date(e) : null, A = /* @__PURE__ */ new Map(), j = t(({ config: e, children: t }) => {
81
+ let [r, c] = s(() => {
82
+ let t = g() ? S(e.licenseKey, e) : null;
83
+ return {
84
+ payload: t?.payload ?? null,
85
+ source: t ? "cache" : "none",
86
+ isStale: t ? Date.parse(t.payload.expiresAt) < Date.now() : !1,
87
+ isLoading: !0,
88
+ error: null
89
+ };
90
+ }), u = o(0), d = o(null), f = o(e.onStatusChange);
91
+ f.current = e.onStatusChange;
92
+ let p = n(async () => {
93
+ if (!g()) return;
94
+ d.current?.abort();
95
+ let t = new AbortController();
96
+ d.current = t;
97
+ let n = `${e.apiUrl ?? ""}|${e.licenseKey}`, r = A.get(n), i = r ?? T({
98
+ licenseKey: e.licenseKey,
99
+ apiUrl: e.apiUrl,
100
+ storage: e.storage,
101
+ maxCacheAgeMs: e.maxCacheAgeMs,
102
+ fetchImpl: e.fetchImpl,
103
+ signal: t.signal
104
+ });
105
+ r || A.set(n, i);
106
+ try {
107
+ let n = await i;
108
+ if (t.signal.aborted) return;
109
+ u.current = n.source === "network" ? 0 : u.current + 1, c({
110
+ payload: n.payload,
111
+ source: n.source,
112
+ isStale: n.isStale,
113
+ isLoading: !1,
114
+ error: n.error
115
+ }), e.debug && console.info("[paywall]", n.source, n.payload?.status, n.error);
116
+ } finally {
117
+ r || A.delete(n);
118
+ }
119
+ }, [
120
+ e.apiUrl,
121
+ e.licenseKey,
122
+ e.storage,
123
+ e.maxCacheAgeMs,
124
+ e.fetchImpl,
125
+ e.debug
126
+ ]);
127
+ i(() => {
128
+ if (!g()) return;
129
+ p();
130
+ let t = r.payload?.refreshAfterSeconds, n = e.refreshIntervalMs ?? 9e5, i = Math.min(n, t ? t * 1e3 : n), a = u.current > 0 ? Math.min(h, m * 2 ** Math.min(u.current, 5)) : i, o = setInterval(() => void p(), a);
131
+ return () => {
132
+ clearInterval(o), d.current?.abort();
133
+ };
134
+ }, [
135
+ p,
136
+ e.refreshIntervalMs,
137
+ r.payload
138
+ ]), i(() => {
139
+ if (!g()) return;
140
+ let e = () => {
141
+ document.visibilityState === "visible" && p();
142
+ };
143
+ return document.addEventListener("visibilitychange", e), window.addEventListener("online", e), () => {
144
+ document.removeEventListener("visibilitychange", e), window.removeEventListener("online", e);
145
+ };
146
+ }, [p]);
147
+ let _ = a(() => {
148
+ let e = r.payload;
149
+ return {
150
+ state: e?.status ?? "OK",
151
+ reason: e?.reason ?? "NONE",
152
+ isBlocked: e?.status === "BLOCKED",
153
+ isLoading: r.isLoading,
154
+ isStale: r.isStale,
155
+ source: r.source,
156
+ graceUntil: k(e?.graceUntil),
157
+ nextTransitionAt: k(e?.nextTransitionAt),
158
+ message: e?.message ?? null,
159
+ contactUrl: e?.contactUrl ?? null,
160
+ payload: e,
161
+ error: r.error,
162
+ refresh: () => void p()
163
+ };
164
+ }, [r, p]);
165
+ i(() => {
166
+ f.current?.(_);
167
+ }, [_]);
168
+ let v = a(() => ({
169
+ status: _,
170
+ config: e
171
+ }), [_, e]);
172
+ return /* @__PURE__ */ l(O.Provider, {
173
+ value: v,
174
+ children: t
175
+ });
176
+ });
177
+ j.displayName = "PaywallProvider";
178
+ //#endregion
179
+ //#region src/core/resolveGateDecision.ts
180
+ var M = ["BLOCKED"], N = {
181
+ OK: "allow",
182
+ WARNING: "warn",
183
+ GRACE: "grace",
184
+ BLOCKED: "grace"
185
+ }, P = (e, t) => {
186
+ let n = N[e] ?? "allow";
187
+ return e === "OK" ? "allow" : (t.blockOn ?? M).includes(e) ? "block" : n;
188
+ }, F = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", I = {
189
+ position: "fixed",
190
+ inset: 0,
191
+ zIndex: 2147483647,
192
+ display: "flex",
193
+ alignItems: "center",
194
+ justifyContent: "center",
195
+ padding: "24px",
196
+ background: "rgba(9, 9, 11, 0.92)",
197
+ backdropFilter: "blur(6px)",
198
+ fontFamily: F,
199
+ color: "#fafafa",
200
+ overflowY: "auto"
201
+ }, L = {
202
+ width: "100%",
203
+ maxWidth: "440px",
204
+ background: "#18181b",
205
+ border: "1px solid #27272a",
206
+ borderRadius: "16px",
207
+ padding: "32px",
208
+ boxShadow: "0 24px 48px rgba(0, 0, 0, 0.45)",
209
+ textAlign: "center"
210
+ }, R = {
211
+ display: "inline-flex",
212
+ alignItems: "center",
213
+ justifyContent: "center",
214
+ width: "48px",
215
+ height: "48px",
216
+ borderRadius: "50%",
217
+ background: "rgba(239, 68, 68, 0.14)",
218
+ color: "#f87171",
219
+ fontSize: "24px",
220
+ marginBottom: "20px"
221
+ }, z = {
222
+ margin: "0 0 12px",
223
+ fontSize: "20px",
224
+ fontWeight: 600,
225
+ lineHeight: 1.3
226
+ }, B = {
227
+ margin: "0 0 24px",
228
+ fontSize: "15px",
229
+ lineHeight: 1.55,
230
+ color: "#a1a1aa"
231
+ }, V = {
232
+ display: "inline-block",
233
+ padding: "11px 22px",
234
+ borderRadius: "10px",
235
+ background: "#fafafa",
236
+ color: "#18181b",
237
+ fontSize: "14px",
238
+ fontWeight: 600,
239
+ textDecoration: "none"
240
+ }, H = {
241
+ margin: "20px 0 0",
242
+ fontSize: "12px",
243
+ color: "#71717a"
244
+ }, U = {
245
+ position: "sticky",
246
+ top: 0,
247
+ zIndex: 2147483e3,
248
+ display: "flex",
249
+ alignItems: "center",
250
+ gap: "12px",
251
+ padding: "10px 16px",
252
+ fontFamily: F,
253
+ fontSize: "14px",
254
+ lineHeight: 1.4,
255
+ boxSizing: "border-box"
256
+ }, W = {
257
+ ...U,
258
+ background: "#fef3c7",
259
+ color: "#78350f",
260
+ borderBottom: "1px solid #fcd34d"
261
+ }, G = {
262
+ ...U,
263
+ background: "#fee2e2",
264
+ color: "#7f1d1d",
265
+ borderBottom: "1px solid #fca5a5"
266
+ }, K = {
267
+ color: "inherit",
268
+ fontWeight: 600,
269
+ textDecoration: "underline",
270
+ whiteSpace: "nowrap"
271
+ }, q = {
272
+ marginLeft: "auto",
273
+ padding: "2px 8px",
274
+ border: "none",
275
+ borderRadius: "6px",
276
+ background: "transparent",
277
+ color: "inherit",
278
+ fontSize: "18px",
279
+ lineHeight: 1,
280
+ cursor: "pointer"
281
+ }, J = {
282
+ flex: 1,
283
+ minWidth: 0
284
+ }, Y = "Este servicio está temporalmente suspendido. Ponte en contacto con el proveedor para restablecerlo.", X = t(({ status: e, config: t }) => {
285
+ let n = a(() => t.productName ? `${t.productName} no está disponible` : "Servicio no disponible", [t.productName]);
286
+ return /* @__PURE__ */ l("div", {
287
+ style: I,
288
+ role: "alertdialog",
289
+ "aria-modal": "true",
290
+ children: /* @__PURE__ */ u("div", {
291
+ style: L,
292
+ children: [
293
+ /* @__PURE__ */ l("div", {
294
+ style: R,
295
+ "aria-hidden": "true",
296
+ children: "⚠"
297
+ }),
298
+ /* @__PURE__ */ l("h1", {
299
+ style: z,
300
+ children: n
301
+ }),
302
+ /* @__PURE__ */ l("p", {
303
+ style: B,
304
+ children: e.message ?? Y
305
+ }),
306
+ e.contactUrl ? /* @__PURE__ */ l("a", {
307
+ style: V,
308
+ href: e.contactUrl,
309
+ target: "_blank",
310
+ rel: "noreferrer noopener",
311
+ children: "Resolver ahora"
312
+ }) : null,
313
+ t.contactEmail ? /* @__PURE__ */ u("p", {
314
+ style: H,
315
+ children: [
316
+ "¿Dudas? Escribe a",
317
+ " ",
318
+ /* @__PURE__ */ l("a", {
319
+ href: `mailto:${t.contactEmail}`,
320
+ style: K,
321
+ children: t.contactEmail
322
+ })
323
+ ]
324
+ }) : null
325
+ ]
326
+ })
327
+ });
328
+ });
329
+ X.displayName = "BlockedOverlay";
330
+ //#endregion
331
+ //#region src/react/ui/StatusBanner.tsx
332
+ var Z = (e) => {
333
+ if (!e) return null;
334
+ let t = e.getTime() - Date.now();
335
+ if (t <= 0) return null;
336
+ let n = Math.floor(t / 864e5);
337
+ if (n >= 1) return n === 1 ? "1 día" : `${n} días`;
338
+ let r = Math.max(1, Math.floor(t / 36e5));
339
+ return r === 1 ? "1 hora" : `${r} horas`;
340
+ }, Q = t(({ status: e, variant: t }) => {
341
+ let [n, r] = s(!1), i = a(() => Z(e.graceUntil), [e.graceUntil]);
342
+ return n ? null : /* @__PURE__ */ u("div", {
343
+ style: t === "grace" ? G : W,
344
+ role: "status",
345
+ children: [
346
+ /* @__PURE__ */ u("span", {
347
+ style: J,
348
+ children: [e.message, i ? /* @__PURE__ */ u("strong", { children: [
349
+ " El servicio se suspenderá en ",
350
+ i,
351
+ "."
352
+ ] }) : null]
353
+ }),
354
+ e.contactUrl ? /* @__PURE__ */ l("a", {
355
+ style: K,
356
+ href: e.contactUrl,
357
+ target: "_blank",
358
+ rel: "noreferrer noopener",
359
+ children: "Resolver"
360
+ }) : null,
361
+ t === "warning" ? /* @__PURE__ */ l("button", {
362
+ type: "button",
363
+ style: q,
364
+ onClick: () => r(!0),
365
+ "aria-label": "Descartar aviso",
366
+ children: "×"
367
+ }) : null
368
+ ]
369
+ });
370
+ });
371
+ Q.displayName = "StatusBanner";
372
+ //#endregion
373
+ //#region src/react/PaywallGate.tsx
374
+ var $ = t(({ children: e, renderBlocked: t, renderWarning: n, renderGrace: i, showBanners: o = !0 }) => {
375
+ let s = r(O);
376
+ if (!s) throw Error("[paywall] <PaywallGate> debe usarse dentro de <PaywallProvider>.");
377
+ let { status: d, config: f } = s, p = a(() => P(d.state, f), [d.state, f]);
378
+ if (p === "block") return /* @__PURE__ */ l(c, { children: t ? t(d) : /* @__PURE__ */ l(X, {
379
+ status: d,
380
+ config: f
381
+ }) });
382
+ let m = o ? p === "warn" ? n ? n(d) : /* @__PURE__ */ l(Q, {
383
+ status: d,
384
+ variant: "warning"
385
+ }) : p === "grace" ? i ? i(d) : /* @__PURE__ */ l(Q, {
386
+ status: d,
387
+ variant: "grace"
388
+ }) : null : null;
389
+ return /* @__PURE__ */ u(c, { children: [m, e] });
390
+ });
391
+ $.displayName = "PaywallGate";
392
+ //#endregion
393
+ //#region src/react/useLicenseStatus.ts
394
+ var te = () => {
395
+ let e = r(O);
396
+ if (!e) throw Error("[paywall] useLicenseStatus debe usarse dentro de <PaywallProvider>.");
397
+ return e.status;
398
+ };
399
+ //#endregion
400
+ export { $ as PaywallGate, j as PaywallProvider, C as clearLicenseCache, te as useLicenseStatus };
@@ -0,0 +1,6 @@
1
+ import { LicenseStatusI, PaywallConfigI } from '../types';
2
+ export interface PaywallContextValueI {
3
+ status: LicenseStatusI;
4
+ config: PaywallConfigI;
5
+ }
6
+ export declare const PaywallContext: import('react').Context<PaywallContextValueI | null>;
@@ -0,0 +1,10 @@
1
+ import { ReactElement } from 'react';
2
+ import { PaywallGatePropsI } from '../types';
3
+ /**
4
+ * Envuelve la aplicación y decide si se ve.
5
+ *
6
+ * Bloquear **no renderiza** los hijos en lugar de taparlos con CSS. Ocultar con
7
+ * `display:none` se deshace desde las herramientas de desarrollo en dos clics, y
8
+ * además dejaría corriendo los efectos y las peticiones de la aplicación.
9
+ */
10
+ export declare const PaywallGate: import('react').MemoExoticComponent<({ children, renderBlocked, renderWarning, renderGrace, showBanners, }: PaywallGatePropsI) => ReactElement>;
@@ -0,0 +1,7 @@
1
+ import { ReactNode } from 'react';
2
+ import { PaywallConfigI } from '../types';
3
+ export interface PaywallProviderPropsI {
4
+ config: PaywallConfigI;
5
+ children: ReactNode;
6
+ }
7
+ export declare const PaywallProvider: import('react').MemoExoticComponent<({ config, children }: PaywallProviderPropsI) => import("react").JSX.Element>;
@@ -0,0 +1,6 @@
1
+ import { LicenseStatusI, PaywallConfigI } from '../../types';
2
+ export interface BlockedOverlayPropsI {
3
+ status: LicenseStatusI;
4
+ config: PaywallConfigI;
5
+ }
6
+ export declare const BlockedOverlay: import('react').MemoExoticComponent<({ status, config }: BlockedOverlayPropsI) => import("react").JSX.Element>;
@@ -0,0 +1,7 @@
1
+ import { LicenseStatusI } from '../../types';
2
+ export interface StatusBannerPropsI {
3
+ status: LicenseStatusI;
4
+ /** `grace` no se puede descartar: es el último aviso antes del corte. */
5
+ variant: "warning" | "grace";
6
+ }
7
+ export declare const StatusBanner: import('react').MemoExoticComponent<({ status, variant }: StatusBannerPropsI) => import("react").JSX.Element | null>;
@@ -0,0 +1,14 @@
1
+ import { CSSProperties } from 'react';
2
+ export declare const overlay: CSSProperties;
3
+ export declare const card: CSSProperties;
4
+ export declare const badge: CSSProperties;
5
+ export declare const title: CSSProperties;
6
+ export declare const body: CSSProperties;
7
+ export declare const action: CSSProperties;
8
+ export declare const footnote: CSSProperties;
9
+ export declare const bannerBase: CSSProperties;
10
+ export declare const bannerWarning: CSSProperties;
11
+ export declare const bannerGrace: CSSProperties;
12
+ export declare const bannerLink: CSSProperties;
13
+ export declare const bannerDismiss: CSSProperties;
14
+ export declare const bannerText: CSSProperties;
@@ -0,0 +1,7 @@
1
+ import { LicenseStatusI } from '../types';
2
+ /**
3
+ * Estado de licencia en crudo, para pintar avisos a medida sin usar
4
+ * `<PaywallGate>`. Fuera de un `<PaywallProvider>` lanza: es un error de
5
+ * integración, no algo que deba degradarse en silencio.
6
+ */
7
+ export declare const useLicenseStatus: () => LicenseStatusI;
@@ -0,0 +1,78 @@
1
+ export type LicenseStateT = "OK" | "WARNING" | "GRACE" | "BLOCKED";
2
+ export type LicenseReasonT = "NONE" | "PAYMENT_OVERDUE" | "SUBSCRIPTION_INACTIVE" | "MANUAL_BLOCK" | "MANUAL_ALLOW" | "LICENSE_REVOKED";
3
+ /** De dónde salió el estado que se está mostrando. */
4
+ export type LicenseSourceT = "network" | "cache" | "none";
5
+ /** Contrato público del servidor: `GET /api/public/license/v1/status`. */
6
+ export interface LicensePayloadI {
7
+ v: number;
8
+ status: LicenseStateT;
9
+ reason: LicenseReasonT;
10
+ licenseId: string;
11
+ projectId: string;
12
+ graceUntil: string | null;
13
+ blockedAt: string | null;
14
+ nextTransitionAt: string | null;
15
+ checkedAt: string;
16
+ expiresAt: string;
17
+ refreshAfterSeconds: number;
18
+ message: string;
19
+ contactUrl: string;
20
+ warnings: string[];
21
+ token: string;
22
+ /** Solo si la licencia tiene `exposeDetails` activado. */
23
+ amountDue?: number;
24
+ overdueCount?: number;
25
+ daysOverdue?: number;
26
+ projectTitle?: string;
27
+ }
28
+ export interface PaywallConfigI {
29
+ /** Key `cpl_…` emitida desde el panel de administración. */
30
+ licenseKey: string;
31
+ apiUrl?: string;
32
+ refreshIntervalMs?: number;
33
+ /** Estados que tapan la aplicación. Por defecto solo `BLOCKED`. */
34
+ blockOn?: LicenseStateT[];
35
+ /**
36
+ * Qué hacer si el servidor no responde. Por defecto `true`: se deja pasar.
37
+ * Ponerlo a `false` significa que una caída del servidor de licencias tumba
38
+ * la aplicación del cliente.
39
+ */
40
+ failOpen?: boolean;
41
+ maxCacheAgeMs?: number;
42
+ storage?: "local" | "memory" | "none";
43
+ contactEmail?: string;
44
+ productName?: string;
45
+ debug?: boolean;
46
+ onStatusChange?: (status: LicenseStatusI) => void;
47
+ /** Inyectable para tests y entornos sin `fetch` global. */
48
+ fetchImpl?: typeof fetch;
49
+ }
50
+ export interface LicenseStatusI {
51
+ state: LicenseStateT;
52
+ reason: LicenseReasonT;
53
+ isBlocked: boolean;
54
+ isLoading: boolean;
55
+ /** El estado viene de caché caducada: se está usando a falta de algo mejor. */
56
+ isStale: boolean;
57
+ source: LicenseSourceT;
58
+ graceUntil: Date | null;
59
+ nextTransitionAt: Date | null;
60
+ message: string | null;
61
+ contactUrl: string | null;
62
+ payload: LicensePayloadI | null;
63
+ error: Error | null;
64
+ refresh: () => void;
65
+ }
66
+ export type GateDecisionT = "allow" | "warn" | "grace" | "block";
67
+ export interface PaywallGatePropsI {
68
+ children: React.ReactNode;
69
+ renderBlocked?: (status: LicenseStatusI) => React.ReactNode;
70
+ renderWarning?: (status: LicenseStatusI) => React.ReactNode;
71
+ renderGrace?: (status: LicenseStatusI) => React.ReactNode;
72
+ showBanners?: boolean;
73
+ }
74
+ export interface CachedEntryI {
75
+ version: 1;
76
+ payload: LicensePayloadI;
77
+ receivedAt: number;
78
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@cosmo-frameworks/paywall-react",
3
+ "version": "0.1.0",
4
+ "description": "Bloqueo remoto de aplicaciones React por estado de pago.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "license": "UNLICENSED",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/cosmo-frameworks/paywall-react.git"
11
+ },
12
+ "private": false,
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "main": "./dist/index.cjs",
19
+ "module": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "require": "./dist/index.cjs"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc --noEmit && vite build",
31
+ "dev": "vite build --watch",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "lint": "eslint src"
35
+ },
36
+ "peerDependencies": {
37
+ "react": ">=18",
38
+ "react-dom": ">=18"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react-dom": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "dependencies": {},
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "devDependencies": {
53
+ "@testing-library/react": "^16.3.2",
54
+ "@types/react": "^19.2.14",
55
+ "@vitejs/plugin-react": "^6.0.1",
56
+ "happy-dom": "^20.9.0",
57
+ "react": "^19.2.4",
58
+ "react-dom": "^19.2.4",
59
+ "typescript": "~6.0.2",
60
+ "vite": "^8.0.4",
61
+ "vite-plugin-dts": "^4.5.4",
62
+ "vitest": "^4.1.5"
63
+ }
64
+ }