@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 +57 -0
- package/README.md +225 -0
- package/dist/constants.d.ts +14 -0
- package/dist/core/cache.d.ts +10 -0
- package/dist/core/fetchLicenseStatus.d.ts +21 -0
- package/dist/core/isBrowser.d.ts +5 -0
- package/dist/core/resolveGateDecision.d.ts +10 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +400 -0
- package/dist/react/PaywallContext.d.ts +6 -0
- package/dist/react/PaywallGate.d.ts +10 -0
- package/dist/react/PaywallProvider.d.ts +7 -0
- package/dist/react/ui/BlockedOverlay.d.ts +6 -0
- package/dist/react/ui/StatusBanner.d.ts +7 -0
- package/dist/react/ui/styles.d.ts +14 -0
- package/dist/react/useLicenseStatus.d.ts +7 -0
- package/dist/types.d.ts +78 -0
- package/package.json +64 -0
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,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;
|
package/dist/index.d.ts
ADDED
|
@@ -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,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;
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|