@modernman00/shared-js-lib 1.2.82 → 1.2.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * CardCanvasGenerator.js
3
+ * High-resolution 1200x630 / 1200x675 HTML5 Canvas Social Card Generator.
4
+ * Part of @modernman00/shared-js-lib
5
+ */
6
+
7
+ export class CardCanvasGenerator {
8
+ /**
9
+ * Default configuration options
10
+ */
11
+ static get DEFAULT_OPTIONS() {
12
+ return {
13
+ width: 1200,
14
+ height: 675,
15
+ brandName: 'Platform',
16
+ brandBadge: 'DECISION INTELLIGENCE',
17
+ title: 'Strategic Analysis',
18
+ subtitle: '',
19
+ score: 85,
20
+ maxScore: 100,
21
+ scoreLabel: 'SCORE',
22
+ verdict: 'STRONG GO',
23
+ verdictColor: '#10b981', // emerald
24
+ primaryColor: '#10b981',
25
+ secondaryColor: '#3b82f6',
26
+ accentColor: '#6366f1',
27
+ backgroundColor: '#090d16',
28
+ cardBackground: 'rgba(255, 255, 255, 0.03)',
29
+ borderColor: 'rgba(255, 255, 255, 0.1)',
30
+ textColor: '#ffffff',
31
+ mutedTextColor: '#94a3b8',
32
+ watermarkText: 'Generated with Decision Intelligence Platform',
33
+ metrics: [], // Array of { label: string, score: number, maxScore?: number }
34
+ showGauge: true,
35
+ showMetrics: true
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Renders the card onto a target HTML5 canvas element
41
+ * @param {HTMLCanvasElement} canvas
42
+ * @param {Object} options
43
+ * @returns {HTMLCanvasElement}
44
+ */
45
+ static renderToCanvas(canvas, options = {}) {
46
+ const opts = { ...this.DEFAULT_OPTIONS, ...options };
47
+ const width = opts.width;
48
+ const height = opts.height;
49
+
50
+ canvas.width = width;
51
+ canvas.height = height;
52
+ const ctx = canvas.getContext('2d');
53
+ if (!ctx) {
54
+ throw new Error('Unable to obtain 2D canvas rendering context.');
55
+ }
56
+
57
+ // 1. Draw Deep Gradient Background
58
+ const bgGrad = ctx.createLinearGradient(0, 0, width, height);
59
+ bgGrad.addColorStop(0, opts.backgroundColor || '#090d16');
60
+ bgGrad.addColorStop(0.5, '#0d1527');
61
+ bgGrad.addColorStop(1, '#050811');
62
+ ctx.fillStyle = bgGrad;
63
+ ctx.fillRect(0, 0, width, height);
64
+
65
+ // 2. Ambient Lighting / Glow Orbs
66
+ this._drawGlowOrb(ctx, 150, 120, 250, opts.primaryColor, 0.18);
67
+ this._drawGlowOrb(ctx, width - 200, height - 150, 300, opts.secondaryColor, 0.15);
68
+
69
+ // 3. Subtle Grid Pattern
70
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.025)';
71
+ ctx.lineWidth = 1;
72
+ for (let x = 40; x < width; x += 60) {
73
+ ctx.beginPath();
74
+ ctx.moveTo(x, 0);
75
+ ctx.lineTo(x, height);
76
+ ctx.stroke();
77
+ }
78
+ for (let y = 40; y < height; y += 60) {
79
+ ctx.beginPath();
80
+ ctx.moveTo(0, y);
81
+ ctx.lineTo(width, y);
82
+ ctx.stroke();
83
+ }
84
+
85
+ // 4. Main Glassmorphic Card Container
86
+ const margin = 40;
87
+ const cardW = width - margin * 2;
88
+ const cardH = height - margin * 2;
89
+ this._drawRoundedRect(ctx, margin, margin, cardW, cardH, 24);
90
+ ctx.fillStyle = opts.cardBackground;
91
+ ctx.fill();
92
+ ctx.strokeStyle = opts.borderColor;
93
+ ctx.lineWidth = 1.5;
94
+ ctx.stroke();
95
+
96
+ // 5. Header: Brand & Badge
97
+ ctx.font = 'bold 26px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
98
+ ctx.fillStyle = opts.textColor;
99
+ ctx.textAlign = 'left';
100
+ ctx.textBaseline = 'middle';
101
+ ctx.fillText(opts.brandName, margin + 40, margin + 50);
102
+
103
+ if (opts.brandBadge) {
104
+ const brandWidth = ctx.measureText(opts.brandName).width;
105
+ const badgeX = margin + 40 + brandWidth + 16;
106
+ const badgeY = margin + 37;
107
+ const badgeText = String(opts.brandBadge).toUpperCase();
108
+ ctx.font = 'bold 12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
109
+ const badgeW = ctx.measureText(badgeText).width + 24;
110
+
111
+ this._drawRoundedRect(ctx, badgeX, badgeY, badgeW, 26, 6);
112
+ ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
113
+ ctx.fill();
114
+ ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
115
+ ctx.lineWidth = 1;
116
+ ctx.stroke();
117
+
118
+ ctx.fillStyle = opts.primaryColor;
119
+ ctx.fillText(badgeText, badgeX + 12, badgeY + 13);
120
+ }
121
+
122
+ // Header Divider Line
123
+ ctx.beginPath();
124
+ ctx.moveTo(margin + 40, margin + 85);
125
+ ctx.lineTo(margin + cardW - 40, margin + 85);
126
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
127
+ ctx.lineWidth = 1;
128
+ ctx.stroke();
129
+
130
+ // 6. Left Column: Circular Glowing Gauge (if enabled)
131
+ const hasGauge = Boolean(opts.showGauge);
132
+ const leftColWidth = hasGauge ? 340 : 0;
133
+ const contentStartX = margin + 40 + leftColWidth;
134
+
135
+ if (hasGauge) {
136
+ const gaugeCenterX = margin + 170;
137
+ const gaugeCenterY = margin + 280;
138
+ const gaugeRadius = 110;
139
+
140
+ // Outer track
141
+ ctx.beginPath();
142
+ ctx.arc(gaugeCenterX, gaugeCenterY, gaugeRadius, 0, Math.PI * 2);
143
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
144
+ ctx.lineWidth = 16;
145
+ ctx.lineCap = 'round';
146
+ ctx.stroke();
147
+
148
+ // Active Arc
149
+ const scorePct = Math.max(0, Math.min(100, opts.score)) / (opts.maxScore || 100);
150
+ const startAngle = -Math.PI / 2;
151
+ const endAngle = startAngle + scorePct * (Math.PI * 2);
152
+
153
+ ctx.beginPath();
154
+ ctx.arc(gaugeCenterX, gaugeCenterY, gaugeRadius, startAngle, endAngle);
155
+ const gaugeGrad = ctx.createLinearGradient(
156
+ gaugeCenterX - gaugeRadius,
157
+ gaugeCenterY - gaugeRadius,
158
+ gaugeCenterX + gaugeRadius,
159
+ gaugeCenterY + gaugeRadius
160
+ );
161
+ gaugeGrad.addColorStop(0, opts.primaryColor);
162
+ gaugeGrad.addColorStop(1, opts.secondaryColor);
163
+ ctx.strokeStyle = gaugeGrad;
164
+ ctx.lineWidth = 16;
165
+ ctx.stroke();
166
+
167
+ // Score Value
168
+ ctx.textAlign = 'center';
169
+ ctx.textBaseline = 'middle';
170
+ ctx.font = 'bold 64px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
171
+ ctx.fillStyle = opts.textColor;
172
+ ctx.fillText(`${Math.round(opts.score)}%`, gaugeCenterX, gaugeCenterY - 10);
173
+
174
+ ctx.font = '600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
175
+ ctx.fillStyle = opts.mutedTextColor;
176
+ ctx.fillText(opts.scoreLabel, gaugeCenterX, gaugeCenterY + 36);
177
+
178
+ // Verdict Pill below gauge
179
+ if (opts.verdict) {
180
+ const pillY = gaugeCenterY + gaugeRadius + 30;
181
+ ctx.font = 'bold 15px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
182
+ const pillText = String(opts.verdict).toUpperCase();
183
+ const pillW = ctx.measureText(pillText).width + 36;
184
+ const pillH = 34;
185
+
186
+ this._drawRoundedRect(ctx, gaugeCenterX - pillW / 2, pillY, pillW, pillH, 17);
187
+ ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
188
+ ctx.fill();
189
+ ctx.strokeStyle = opts.verdictColor;
190
+ ctx.lineWidth = 1.5;
191
+ ctx.stroke();
192
+
193
+ ctx.fillStyle = opts.verdictColor;
194
+ ctx.fillText(pillText, gaugeCenterX, pillY + pillH / 2);
195
+ }
196
+ }
197
+
198
+ // 7. Right Column: Headline, Subtitle & Metrics
199
+ ctx.textAlign = 'left';
200
+ ctx.textBaseline = 'top';
201
+
202
+ // Title (multi-line word wrap)
203
+ ctx.font = 'bold 38px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
204
+ ctx.fillStyle = opts.textColor;
205
+ const maxTitleW = cardW - (contentStartX - margin) - 40;
206
+ const titleLines = this._wrapText(ctx, opts.title, maxTitleW);
207
+ let curY = margin + 120;
208
+
209
+ titleLines.slice(0, 2).forEach((line) => {
210
+ ctx.fillText(line, contentStartX, curY);
211
+ curY += 48;
212
+ });
213
+
214
+ // Subtitle
215
+ if (opts.subtitle) {
216
+ curY += 6;
217
+ ctx.font = '500 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
218
+ ctx.fillStyle = opts.mutedTextColor;
219
+ const subLines = this._wrapText(ctx, opts.subtitle, maxTitleW);
220
+ subLines.slice(0, 2).forEach((line) => {
221
+ ctx.fillText(line, contentStartX, curY);
222
+ curY += 26;
223
+ });
224
+ }
225
+
226
+ // Metrics Breakdown Bars (if provided)
227
+ if (opts.showMetrics && Array.isArray(opts.metrics) && opts.metrics.length > 0) {
228
+ curY += 24;
229
+ const barTotalWidth = Math.min(maxTitleW, 640);
230
+ const metricsToShow = opts.metrics.slice(0, 4);
231
+
232
+ metricsToShow.forEach((metric) => {
233
+ const mScore = Number(metric.score ?? 0);
234
+ const mMax = Number(metric.maxScore ?? 10);
235
+ const mRatio = Math.max(0, Math.min(1, mMax > 0 ? mScore / mMax : 0));
236
+
237
+ // Metric label & score
238
+ ctx.font = '600 15px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
239
+ ctx.fillStyle = '#e2e8f0';
240
+ ctx.fillText(metric.label, contentStartX, curY);
241
+
242
+ ctx.textAlign = 'right';
243
+ ctx.fillStyle = opts.primaryColor;
244
+ ctx.fillText(`${mScore} / ${mMax}`, contentStartX + barTotalWidth, curY);
245
+ ctx.textAlign = 'left';
246
+
247
+ // Track bar
248
+ const barY = curY + 24;
249
+ const barH = 8;
250
+ this._drawRoundedRect(ctx, contentStartX, barY, barTotalWidth, barH, 4);
251
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
252
+ ctx.fill();
253
+
254
+ // Fill bar
255
+ if (mRatio > 0) {
256
+ const fillW = Math.max(8, barTotalWidth * mRatio);
257
+ this._drawRoundedRect(ctx, contentStartX, barY, fillW, barH, 4);
258
+ const barGrad = ctx.createLinearGradient(contentStartX, barY, contentStartX + fillW, barY);
259
+ barGrad.addColorStop(0, opts.primaryColor);
260
+ barGrad.addColorStop(1, opts.secondaryColor);
261
+ ctx.fillStyle = barGrad;
262
+ ctx.fill();
263
+ }
264
+
265
+ curY += 46;
266
+ });
267
+ }
268
+
269
+ // 8. Footer Watermark & Domain
270
+ const footerY = margin + cardH - 35;
271
+ ctx.font = '500 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
272
+ ctx.fillStyle = opts.mutedTextColor;
273
+ ctx.textAlign = 'left';
274
+ ctx.textBaseline = 'middle';
275
+ ctx.fillText(opts.watermarkText, margin + 40, footerY);
276
+
277
+ return canvas;
278
+ }
279
+
280
+ /**
281
+ * Generates a PNG Blob from options
282
+ * @param {Object} options
283
+ * @param {string} [type='image/png']
284
+ * @param {number} [quality=0.95]
285
+ * @returns {Promise<Blob>}
286
+ */
287
+ static generateBlob(options = {}, type = 'image/png', quality = 0.95) {
288
+ return new Promise((resolve, reject) => {
289
+ try {
290
+ const canvas = document.createElement('canvas');
291
+ this.renderToCanvas(canvas, options);
292
+ canvas.toBlob((blob) => {
293
+ if (blob) {
294
+ resolve(blob);
295
+ } else {
296
+ reject(new Error('Canvas toBlob returned null'));
297
+ }
298
+ }, type, quality);
299
+ } catch (err) {
300
+ reject(err);
301
+ }
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Generates a Data URL string from options
307
+ * @param {Object} options
308
+ * @param {string} [type='image/png']
309
+ * @returns {string}
310
+ */
311
+ static generateDataUrl(options = {}, type = 'image/png') {
312
+ const canvas = document.createElement('canvas');
313
+ this.renderToCanvas(canvas, options);
314
+ return canvas.toDataURL(type);
315
+ }
316
+
317
+ // --- Internal Drawing Helpers ---
318
+
319
+ static _drawGlowOrb(ctx, x, y, radius, color, alpha) {
320
+ ctx.save();
321
+ ctx.beginPath();
322
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
323
+ const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
324
+ grad.addColorStop(0, color);
325
+ grad.addColorStop(1, 'transparent');
326
+ ctx.fillStyle = grad;
327
+ ctx.globalAlpha = alpha;
328
+ ctx.fill();
329
+ ctx.restore();
330
+ }
331
+
332
+ static _drawRoundedRect(ctx, x, y, width, height, radius) {
333
+ ctx.beginPath();
334
+ ctx.moveTo(x + radius, y);
335
+ ctx.lineTo(x + width - radius, y);
336
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
337
+ ctx.lineTo(x + width, y + height - radius);
338
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
339
+ ctx.lineTo(x + radius, y + height);
340
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
341
+ ctx.lineTo(x, y + radius);
342
+ ctx.quadraticCurveTo(x, y, x + radius, y);
343
+ ctx.closePath();
344
+ }
345
+
346
+ static _wrapText(ctx, text, maxWidth) {
347
+ if (!text) return [];
348
+ const words = String(text).split(' ');
349
+ const lines = [];
350
+ let currentLine = words[0] || '';
351
+
352
+ for (let i = 1; i < words.length; i++) {
353
+ const word = words[i];
354
+ const width = ctx.measureText(`${currentLine} ${word}`).width;
355
+ if (width < maxWidth) {
356
+ currentLine += ` ${word}`;
357
+ } else {
358
+ lines.push(currentLine);
359
+ currentLine = word;
360
+ }
361
+ }
362
+ if (currentLine) {
363
+ lines.push(currentLine);
364
+ }
365
+ return lines;
366
+ }
367
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * 📦 OfflineQueue — Resilient IndexedDB Offline Request Queue
3
+ * Part of @modernman00/shared-js-lib
4
+ *
5
+ * Implements the Deloitte Digital & BRATS-approved enterprise architecture:
6
+ * 1. UUIDv4 Idempotency Key injection ('X-Idempotency-Key') to eliminate duplicate writes
7
+ * 2. Strict Security Blacklist: Refuses to store credentials, passwords, or /auth/* /admin/* requests
8
+ * 3. Kieran Memory Cap: 2MB max per payload, 30 items max queue depth
9
+ * 4. 24-Hour automatic request expiration
10
+ * 5. Automatic replay on 'online' event with fresh CSRF token header
11
+ */
12
+
13
+ export class OfflineQueue {
14
+ constructor(options = {}) {
15
+ this.appName = options.appName || (typeof window !== 'undefined' ? window.location.hostname.replace(/[^a-zA-Z0-9]/g, '_') : 'app');
16
+ this.dbName = options.dbName || `${this.appName}_offline_db`;
17
+ this.storeName = 'offline_requests';
18
+ this.dbVersion = 1;
19
+ this.maxItems = options.maxItems || 30;
20
+ this.maxPayloadBytes = options.maxPayloadBytes || 2 * 1024 * 1024; // 2MB cap
21
+ this.maxAgeMs = options.maxAgeMs || 24 * 60 * 60 * 1000; // 24 hours
22
+ this.db = null;
23
+ this.isReplaying = false;
24
+
25
+ // Security Blacklist (never cache credentials/auth/passwords offline)
26
+ this.blacklistPatterns = [
27
+ /\/auth\//i,
28
+ /\/admin\//i,
29
+ /\/login/i,
30
+ /\/logout/i,
31
+ /\/signout/i,
32
+ /\/password/i,
33
+ /\/2fa/i,
34
+ /\/totp/i
35
+ ];
36
+
37
+ if (typeof window !== 'undefined') {
38
+ window.addEventListener('online', () => this.replay());
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Generates standard RFC4122 v4 UUID for request idempotency
44
+ */
45
+ static generateUUID() {
46
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
47
+ return crypto.randomUUID();
48
+ }
49
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
50
+ const r = (Math.random() * 16) | 0;
51
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
52
+ return v.toString(16);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Initializes or returns the open IndexedDB instance
58
+ */
59
+ async getDB() {
60
+ if (this.db) return this.db;
61
+ if (typeof indexedDB === 'undefined') {
62
+ throw new Error('IndexedDB is not supported in this browser environment');
63
+ }
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const request = indexedDB.open(this.dbName, this.dbVersion);
67
+
68
+ request.onerror = () => reject(request.error);
69
+ request.onsuccess = () => {
70
+ this.db = request.result;
71
+ resolve(this.db);
72
+ };
73
+ request.onupgradeneeded = (event) => {
74
+ const db = event.target.result;
75
+ if (!db.objectStoreNames.contains(this.storeName)) {
76
+ const store = db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
77
+ store.createIndex('timestamp', 'timestamp', { unique: false });
78
+ store.createIndex('idempotencyKey', 'idempotencyKey', { unique: true });
79
+ }
80
+ };
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Check if URL matches the security blacklist
86
+ */
87
+ isBlacklisted(url) {
88
+ if (!url || typeof url !== 'string') return true;
89
+ return this.blacklistPatterns.some(pattern => pattern.test(url));
90
+ }
91
+
92
+ /**
93
+ * Enqueues an outgoing request for offline storage and later replay
94
+ */
95
+ async enqueue(requestData) {
96
+ const { url, method = 'POST', headers = {}, body = null } = requestData;
97
+
98
+ // Security Gate
99
+ if (this.isBlacklisted(url)) {
100
+ console.warn(`[OfflineQueue] Blocked security-sensitive request from offline storage: ${url}`);
101
+ return false;
102
+ }
103
+
104
+ // Payload Size Guard (Kieran Memory Protection)
105
+ const serializedBody = typeof body === 'string' ? body : JSON.stringify(body || {});
106
+ const bodySize = typeof Blob !== 'undefined' ? new Blob([serializedBody]).size : serializedBody.length;
107
+ if (bodySize > this.maxPayloadBytes) {
108
+ console.warn(`[OfflineQueue] Request payload exceeds 2MB memory cap (${bodySize} bytes). Dropping.`);
109
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
110
+ window.dispatchEvent(new CustomEvent('offlinequeue:payload-too-large', { detail: { url } }));
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Filter headers to strip sensitive authorization tokens from plaintext IndexedDB
116
+ const safeHeaders = {};
117
+ const headerEntries = headers.entries ? Array.from(headers.entries()) : Object.entries(headers || {});
118
+ for (const [key, value] of headerEntries) {
119
+ const lower = key.toLowerCase();
120
+ if (lower !== 'authorization' && lower !== 'cookie' && lower !== 'set-cookie') {
121
+ safeHeaders[key] = value;
122
+ }
123
+ }
124
+
125
+ const idempotencyKey = safeHeaders['x-idempotency-key'] || safeHeaders['X-Idempotency-Key'] || OfflineQueue.generateUUID();
126
+ safeHeaders['X-Idempotency-Key'] = idempotencyKey;
127
+
128
+ const db = await this.getDB();
129
+
130
+ return new Promise((resolve, reject) => {
131
+ const tx = db.transaction(this.storeName, 'readwrite');
132
+ const store = tx.objectStore(this.storeName);
133
+
134
+ // Check max queue length
135
+ const countReq = store.count();
136
+ countReq.onsuccess = () => {
137
+ if (countReq.result >= this.maxItems) {
138
+ console.warn(`[OfflineQueue] Queue reached max depth (${this.maxItems}). Dropping oldest request.`);
139
+ const openCursor = store.openCursor();
140
+ openCursor.onsuccess = (e) => {
141
+ const cursor = e.target.result;
142
+ if (cursor) {
143
+ store.delete(cursor.primaryKey);
144
+ }
145
+ };
146
+ }
147
+
148
+ const item = {
149
+ url,
150
+ method: method.toUpperCase(),
151
+ headers: safeHeaders,
152
+ body: serializedBody,
153
+ idempotencyKey,
154
+ timestamp: Date.now()
155
+ };
156
+
157
+ store.add(item);
158
+ };
159
+
160
+ tx.oncomplete = () => {
161
+ console.log(`[OfflineQueue] Request queued successfully with Idempotency Key: ${idempotencyKey}`);
162
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
163
+ window.dispatchEvent(new CustomEvent('offlinequeue:enqueued', { detail: { url, idempotencyKey } }));
164
+ }
165
+ resolve(idempotencyKey);
166
+ };
167
+ tx.onerror = () => reject(tx.error);
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Replays all stored offline requests sequentially
173
+ */
174
+ async replay() {
175
+ if (this.isReplaying || (typeof navigator !== 'undefined' && !navigator.onLine)) {
176
+ return;
177
+ }
178
+
179
+ this.isReplaying = true;
180
+ const db = await this.getDB();
181
+
182
+ try {
183
+ const items = await new Promise((resolve, reject) => {
184
+ const tx = db.transaction(this.storeName, 'readonly');
185
+ const store = tx.objectStore(this.storeName);
186
+ const req = store.getAll();
187
+ req.onsuccess = () => resolve(req.result || []);
188
+ req.onerror = () => reject(req.error);
189
+ });
190
+
191
+ if (items.length === 0) {
192
+ this.isReplaying = false;
193
+ return;
194
+ }
195
+
196
+ console.log(`[OfflineQueue] Network restored. Replaying ${items.length} queued request(s)...`);
197
+
198
+ // Refresh CSRF Token if available in DOM
199
+ let currentCsrf = null;
200
+ if (typeof document !== 'undefined') {
201
+ const metaCsrf = document.querySelector('meta[name="csrf-token"]') || document.querySelector('meta[name="csrf"]');
202
+ if (metaCsrf) currentCsrf = metaCsrf.getAttribute('content');
203
+ }
204
+
205
+ const now = Date.now();
206
+
207
+ for (const item of items) {
208
+ // Expiration check (24h)
209
+ if (now - item.timestamp > this.maxAgeMs) {
210
+ console.warn(`[OfflineQueue] Request to ${item.url} expired (>24h). Removing from queue.`);
211
+ await this.deleteItem(item.id);
212
+ continue;
213
+ }
214
+
215
+ const headers = { ...item.headers };
216
+ if (currentCsrf) {
217
+ headers['X-CSRF-TOKEN'] = currentCsrf;
218
+ }
219
+
220
+ try {
221
+ const response = await fetch(item.url, {
222
+ method: item.method,
223
+ headers,
224
+ body: item.body
225
+ });
226
+
227
+ // If server successfully processed (2xx or 4xx business error), delete from queue
228
+ // (4xx means invalid input or already processed, no point in infinite retry loop)
229
+ if (response.ok || (response.status >= 400 && response.status < 500)) {
230
+ console.log(`[OfflineQueue] Replayed ${item.url} -> Status ${response.status}`);
231
+ await this.deleteItem(item.id);
232
+ } else {
233
+ console.warn(`[OfflineQueue] Server returned ${response.status} for ${item.url}. Will retry later.`);
234
+ break; // Stop replay if server is returning 5xx errors
235
+ }
236
+ } catch (networkErr) {
237
+ console.warn(`[OfflineQueue] Network still unstable during replay: ${networkErr.message}`);
238
+ break; // Stop loop and wait for next online event
239
+ }
240
+ }
241
+ } catch (err) {
242
+ console.error('[OfflineQueue] Replay cycle error:', err);
243
+ } finally {
244
+ this.isReplaying = false;
245
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
246
+ window.dispatchEvent(new CustomEvent('offlinequeue:replay-complete'));
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Deletes a single item by primary key ID
253
+ */
254
+ async deleteItem(id) {
255
+ const db = await this.getDB();
256
+ return new Promise((resolve, reject) => {
257
+ const tx = db.transaction(this.storeName, 'readwrite');
258
+ const store = tx.objectStore(this.storeName);
259
+ store.delete(id);
260
+ tx.oncomplete = () => resolve();
261
+ tx.onerror = () => reject(tx.error);
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Clears all queued items
267
+ */
268
+ async clear() {
269
+ const db = await this.getDB();
270
+ return new Promise((resolve, reject) => {
271
+ const tx = db.transaction(this.storeName, 'readwrite');
272
+ const store = tx.objectStore(this.storeName);
273
+ store.clear();
274
+ tx.oncomplete = () => resolve();
275
+ tx.onerror = () => reject(tx.error);
276
+ });
277
+ }
278
+ }