cyber-elx 1.0.7 → 1.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.
@@ -0,0 +1,576 @@
1
+ # CyberOcean Custom Payment Page
2
+
3
+ ## Summary
4
+
5
+ - [CyberOcean Custom Payment Page](#cyberocean-custom-payment-page)
6
+ - [Summary](#summary)
7
+ - [Overview](#overview)
8
+ - [Payment Page](#payment-page)
9
+ - [Component Structure](#component-structure)
10
+ - [Available Props](#available-props)
11
+ - [course Object](#course-object)
12
+ - [user Object](#user-object)
13
+ - [Page Sections](#page-sections)
14
+ - [Events to Emit](#events-to-emit)
15
+ - [Example Payment Page:](#example-payment-page)
16
+ - [Vue Component Format](#vue-component-format)
17
+ - [Basic Structure](#basic-structure)
18
+ - [Available Fields](#available-fields)
19
+ - [Key Rules](#key-rules)
20
+ - [Minimal Example](#minimal-example)
21
+
22
+ ## Overview
23
+
24
+ The Payment Page handles course enrollment and payment processing. It displays course information, handles user authentication state, and provides payment options.
25
+ IMPORTANT: You will only handle UI and styles, all logic and functionality is handled by the parent component.
26
+
27
+ **Key Features:**
28
+ - Course information display with price breakdown
29
+ - User authentication check (login/register prompts for guests)
30
+ - Vuetify components for dialogs (Recommended)
31
+
32
+ ## Payment Page
33
+
34
+ The payment page manages the course purchase flow:
35
+ 1. **Guest User** - Prompts to login or register
36
+ 2. **Logged-in User (Paid Course)** - Shows payment method dialog, just emit `payClick` event
37
+ 3. **Logged-in User (Free Course)** - Direct enrollment, just emit `payClick` event
38
+
39
+ ### Component Structure
40
+
41
+ The payment component must export a module with:
42
+ - `name` - Component name (`"PaymentPage"`)
43
+ - `props` - Required props (array or object format)
44
+ - `template` - HTML template string
45
+ - `style` - CSS styles string (optional but recommended)
46
+
47
+ ### Available Props
48
+
49
+ | Prop | Type | Description |
50
+ |------|------|-------------|
51
+ | `loading` | Boolean | Whether a request is in progress |
52
+ | `isFreeCourse` | Boolean | Whether the course is free (`course.price == 0`) |
53
+ | `courseId` | String | The course ID |
54
+ | `course` | Object | Course data object (see below) |
55
+ | `paymentMethods` | Array | List of available payment method names |
56
+ | `showPaymentMethodDialog` | Boolean | Controls payment method dialog visibility |
57
+ | `showSuccessDialog` | Boolean | Controls success dialog visibility |
58
+ | `websiteLogo` | String | Path to the website logo |
59
+ | `user` | Object/null | Current user object or `null` if not logged in |
60
+
61
+ ### course Object
62
+
63
+ The `course` prop contains course information:
64
+
65
+ | Field | Type | Description |
66
+ |-------|------|-------------|
67
+ | `name` | String | Course name |
68
+ | `description` | String | Course description |
69
+ | `price` | Number | Course price (0 if free) |
70
+ | `barredPrice` | String | Original price before discount |
71
+ | `logo.path` | String | Path to course image |
72
+
73
+ ### user Object
74
+
75
+ The `user` prop (when logged in) contains:
76
+
77
+ | Field | Type | Description |
78
+ |-------|------|-------------|
79
+ | `email` | String | User's email address |
80
+ | `name` | String | User's name |
81
+ | `image.path` | String | User's profile image |
82
+
83
+ ### Page Sections
84
+
85
+ 1. **Course Info Section** - Displays course image, name, price, and description
86
+ 2. **Auth Section** - Shows login/register buttons for guests, or user email for logged-in users
87
+ 3. **Payment Methods Display** - Visual display of available payment methods
88
+ 4. **Purchase Info Section** - Price breakdown and pay button
89
+ 5. **Payment Method Dialog** - Modal to choose between online payment or course request
90
+ 6. **Success Dialog** - Confirmation after successful course request
91
+
92
+ ### Events to Emit
93
+
94
+ | Event | When to Emit | Description |
95
+ |-------|--------------|-------------|
96
+ | `redirectToAuth` | Login/Register click | Redirect to auth page: `$emit('redirectToAuth', '@PVP/login')` or `'@PVP/register'` |
97
+ | `payClick` | Pay button click | Initiate payment flow (opens payment method dialog) |
98
+ | `processOnlinePayment` | Online payment selected | Process online payment (redirects to payment gateway) |
99
+ | `requestCourse` | Request course selected | Submit course request (admin will contact user) |
100
+ | `closePaymentMethodDialog` | Dialog close | Close the payment method dialog |
101
+ | `closeSuccessDialog` | Success dialog close | Close the success confirmation dialog |
102
+
103
+ ### Example Payment Page:
104
+ ```js
105
+ module.exports = {
106
+ name: "PaymentPage",
107
+ props: [
108
+ 'loading',
109
+ 'isFreeCourse',
110
+ 'courseId',
111
+ 'course',
112
+ 'paymentMethods',
113
+ 'showPaymentMethodDialog', // Handled by parent component
114
+ 'showSuccessDialog', // Handled by parent component
115
+ 'websiteLogo',
116
+ 'user'
117
+ ],
118
+ template: /* html */`
119
+ <div class="enroll-page-class">
120
+ <v-overlay :value="loading">
121
+ <v-progress-circular indeterminate size="64"></v-progress-circular>
122
+ </v-overlay>
123
+ <div class="d-flex" style="padding: 15px 40px 0px;">
124
+ <img v-if="websiteLogo" :src="websiteLogo" alt="Logo" style="height: 50px;">
125
+ </div>
126
+ <div class="enroll-page-content">
127
+ <div class="course-info">
128
+ <!-- Course Info -->
129
+ <div class="course-holder">
130
+ <img :src="course.logo.path" alt="Course Image" class="course-image" />
131
+ <h2 class="course-title">{{ course.name }}</h2>
132
+ <v-spacer></v-spacer>
133
+ <p class="lesson-price">{{ course.price }} DT</p>
134
+ </div>
135
+ <span style="max-height: 100px; overflow: auto; border-bottom: 1px solid gray; margin-bottom: 20px; font-size: 12px; color: #474747;">
136
+ <b>{{ $t('public-pages.course-payment.description') }}</b>
137
+ {{ course.description }}
138
+ </span>
139
+
140
+ <!-- User Info or Auth -->
141
+ <div v-if="!user">
142
+ <span>{{ $t('public-pages.course-payment.login-to-continue') }}</span>
143
+ <div>
144
+ <v-btn @click="$emit('redirectToAuth', '@PVP/login')" class="normal-btn" color="primary" style="margin-top: 20px; width: 100%;">{{ $t('public-pages.course-payment.login') }}</v-btn>
145
+ <v-btn @click="$emit('redirectToAuth', '@PVP/register')" class="normal-btn" color="primary" style="margin-top: 20px; width: 100%;">{{ $t('public-pages.course-payment.create-account') }}</v-btn>
146
+ </div>
147
+ </div>
148
+ <div v-else style="padding: 2px 7px; border: 2px solid var(--v-primary-base); border-radius: 7px; background-color: #ebf3ff; text-align: center; font-size: 17px;">
149
+ <p style="margin: 0px;">{{ $t('public-pages.course-payment.logged-in-as') }} <b style="color: var(--v-primary-base);">{{ user.email }}</b></p>
150
+ </div>
151
+
152
+ <!-- UI Decoration (Payment Methods) -->
153
+ <v-card style="margin-top: 15px; border-radius: 10px; box-shadow: 0px 0px 20px #00000012 !important; padding-bottom: 10px;" elevation="0">
154
+ <v-card-title style="font-size: 16px;">{{ $t('public-pages.course-payment.available-payment-methods') }}</v-card-title>
155
+ <v-card-text>
156
+ <v-row style="justify-content: center;">
157
+ <v-col
158
+ v-for="(method, index) in paymentMethods"
159
+ :key="index"
160
+ cols="2"
161
+ class="text-center"
162
+ style="min-width: 140px;"
163
+ >
164
+ <img
165
+ :src="'@PS/images/icons/bank-' + (index + 1) + '.png'"
166
+ style="width: 70px; height: 70px; object-fit: contain; border: 1px solid gray; padding: 3px; border-radius: 7px;"
167
+ />
168
+ <div style="font-weight: 500; color: black;">{{ method }}</div>
169
+ </v-col>
170
+ </v-row>
171
+ </v-card-text>
172
+ </v-card>
173
+
174
+ </div>
175
+ <!-- Order Info and Price break down -->
176
+ <div class="purchase-info">
177
+ <p class="item-price"><span>{{ $t('public-pages.course-payment.course-price') }}</span> <span>{{ course.price.toFixed(3) }} DT</span></p>
178
+ <p v-if="isFreeCourse" class="item-price"><span>{{ $t('public-pages.course-payment.original-price') }}</span> <span>{{ parseFloat(course.barredPrice || "100").toFixed(3) }} DT</span></p>
179
+ <p class="total-price"><span>{{ $t('public-pages.course-payment.total') }} </span><span>{{ course.price.toFixed(3) }} DT</span></p>
180
+ <button
181
+ class="payment-button"
182
+ :style="!user ? 'filter: grayscale(1); opacity: 0.8;' : ''"
183
+ @click="$emit('payClick')"
184
+ >
185
+ <span v-if="isFreeCourse">
186
+ {{ $t('public-pages.course-payment.get-course-free') }}
187
+ </span>
188
+ <span v-else>
189
+ {{ $t('public-pages.course-payment.pay') }} {{course.price.toFixed(3)}} DT
190
+ </span>
191
+ </button>
192
+ </div>
193
+
194
+ <!-- Payment Dialog -->
195
+ <v-dialog
196
+ :value="showPaymentMethodDialog"
197
+ @input="$event ? null : $emit('closePaymentMethodDialog')"
198
+ max-width="400px"
199
+ transition="dialog-bottom-transition"
200
+ >
201
+ <v-card class="rounded-lg">
202
+ <v-card-title class="text-center primary white--text">
203
+ <v-icon left color="white">mdi-credit-card-outline</v-icon>
204
+ {{ $t('public-pages.course-payment.choose-payment-method') }}
205
+ </v-card-title>
206
+
207
+ <v-card-text class="pt-6">
208
+ <v-row justify="center" class="mb-4">
209
+ <v-col cols="12" sm="10">
210
+ <v-btn
211
+ block
212
+ color="primary"
213
+ elevation="2"
214
+ class="mb-4 py-6"
215
+ @click="$emit('processOnlinePayment')"
216
+ >
217
+ <v-icon left>mdi-bank</v-icon>
218
+ {{ $t('public-pages.course-payment.online-payment') }}
219
+ <v-icon right>mdi-arrow-right</v-icon>
220
+ </v-btn>
221
+
222
+ <v-btn
223
+ block
224
+ color="secondary"
225
+ elevation="2"
226
+ class="py-6"
227
+ @click="$emit('requestCourse')"
228
+ >
229
+ <v-icon left>mdi-email-outline</v-icon>
230
+ {{ $t('public-pages.course-payment.request-course') }}
231
+ </v-btn>
232
+ </v-col>
233
+ </v-row>
234
+ </v-card-text>
235
+
236
+ <v-divider></v-divider>
237
+
238
+ <v-card-actions>
239
+ <v-spacer></v-spacer>
240
+ <v-btn
241
+ text
242
+ color="grey darken-1"
243
+ @click="$emit('closePaymentMethodDialog')"
244
+ >
245
+ <v-icon left>mdi-close</v-icon>
246
+ {{ $t('public-pages.course-payment.cancel') }}
247
+ </v-btn>
248
+ <v-spacer></v-spacer>
249
+ </v-card-actions>
250
+ </v-card>
251
+ </v-dialog>
252
+
253
+ <!-- Success Dialog -->
254
+ <v-dialog :value="showSuccessDialog" @input="$event ? null : $emit('closeSuccessDialog')" persistent max-width="400">
255
+ <v-card>
256
+ <v-card-title class="headline">{{ $t('public-pages.course-payment.request-sent') }}</v-card-title>
257
+ <v-card-text>
258
+ {{ $t('public-pages.course-payment.request-sent-message') }}
259
+ </v-card-text>
260
+ </v-card>
261
+ </v-dialog>
262
+
263
+ </div>
264
+ </div>
265
+ `,
266
+ style: /* css */`
267
+ .enroll-page-class {
268
+ overflow: auto;
269
+ max-height: calc(100vh - 7px);
270
+ max-width: 1100px;
271
+ margin: auto;
272
+ }
273
+
274
+ .enroll-page-content {
275
+ display: flex;
276
+ flex-direction: column;
277
+ justify-content: space-between;
278
+ gap: 20px;
279
+ padding: 20px;
280
+ }
281
+
282
+ .purchase-info {
283
+ justify-content: flex-start !important;
284
+ gap: 8px;
285
+
286
+ }
287
+
288
+ .course-info,
289
+ .purchase-info {
290
+ background-color: white;
291
+ border-radius: 10px;
292
+ padding: 20px;
293
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* Adds shadow for a lifted look */
294
+ width: 100%;
295
+ display: flex;
296
+ flex-direction: column;
297
+ justify-content: space-between;
298
+
299
+ }
300
+
301
+ .course-info {
302
+ flex-grow: 1;
303
+ }
304
+
305
+ .purchase-info {
306
+ flex-grow: 1;
307
+ }
308
+
309
+ .purchase-info p {
310
+ display: flex;
311
+ flex-direction: row;
312
+ justify-content: space-between;
313
+ }
314
+ .purchase-info p input {
315
+ width: 15%;
316
+ }
317
+ .course-holder {
318
+ display: flex;
319
+ flex-direction: row;
320
+ align-items: center;
321
+ gap: 10px;
322
+ width: 100%;
323
+ margin-bottom: 20px;
324
+ }
325
+
326
+ @media (max-width: 768px) {
327
+ .course-holder {
328
+ flex-direction: column;
329
+ }
330
+ }
331
+
332
+ .course-image {
333
+ object-fit: contain;
334
+ width: 60px;
335
+ border: 1px solid black;
336
+ padding: 5px;
337
+ height: auto;
338
+ border-radius: 6px;
339
+ margin-bottom: 0px;
340
+ max-height: 110px;
341
+ }
342
+ @media (max-width: 768px) {
343
+ .course-image {
344
+ width: 80%;
345
+ margin: auto;
346
+ }
347
+ }
348
+
349
+ .course-title {
350
+ width: 50%;
351
+ max-width: 50%;
352
+ font-size: 1em;
353
+ text-wrap:wrap ;
354
+ }
355
+
356
+ .lesson-price {
357
+ width: fit-content;
358
+ font-size: 1.2em;
359
+ color: #333;
360
+ margin-bottom: 0px !important;
361
+ font-weight: 300;
362
+ font-size: 22px;
363
+ }
364
+
365
+ .payment-methods {
366
+ display: flex;
367
+ justify-content: space-between;
368
+ gap: 10px;
369
+ margin-top: 16px;
370
+ }
371
+
372
+ .payment-option {
373
+ padding: 10px 20px;
374
+ background-color: #255ca838;
375
+ color: white;
376
+ border: none;
377
+ border-radius: 4px;
378
+ cursor: pointer;
379
+ color: #255ca8;
380
+ transition: background-color 0.3s;
381
+ width: 45%;
382
+ font-weight: 600;
383
+ border: 2px solid #255ca8;
384
+
385
+ }
386
+
387
+ .payment-option:hover {
388
+ background-color: #255ca857;
389
+ }
390
+
391
+ .item-price,
392
+ .quantity,
393
+ .total-price {
394
+ font-size: 1em;
395
+ margin: 8px 0;
396
+ }
397
+
398
+ .payment-button {
399
+ width: 100%;
400
+ padding: 12px;
401
+ background-color: #255ca8;
402
+ color: white;
403
+ border: none;
404
+ border-radius: 6px;
405
+ cursor: pointer;
406
+ font-weight: bold;
407
+ }
408
+
409
+ .payment-button:hover {
410
+ background-color: #0056b3;
411
+ }
412
+
413
+ /* Tablet and Desktop Styling */
414
+ @media (min-width: 768px) {
415
+ .enroll-page-content {
416
+ flex-direction: row;
417
+ gap: 20px;
418
+ padding: 40px;
419
+ }
420
+
421
+ .course-info {
422
+ width: 60%;
423
+ }
424
+
425
+ .purchase-info {
426
+ width: 40%;
427
+ }
428
+ }
429
+ .payment-method-modal {
430
+ position: fixed;
431
+ top: 0;
432
+ left: 0;
433
+ width: 100%;
434
+ height: 100%;
435
+ background-color: rgba(0, 0, 0, 0.5);
436
+ display: flex;
437
+ justify-content: center;
438
+ align-items: center;
439
+ z-index: 1000;
440
+ }
441
+
442
+ .payment-method-container {
443
+ background-color: white;
444
+ border-radius: 8px;
445
+ padding: 20px;
446
+ width: 90%;
447
+ max-width: 400px;
448
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
449
+ }
450
+
451
+ .payment-method-container h3 {
452
+ margin-top: 0;
453
+ text-align: center;
454
+ margin-bottom: 20px;
455
+ }
456
+
457
+ .payment-options {
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 12px;
461
+ margin-bottom: 20px;
462
+ }
463
+
464
+ .payment-option {
465
+ padding: 12px;
466
+ border: 1px solid #ddd;
467
+ border-radius: 4px;
468
+ background-color: #f8f8f8;
469
+ cursor: pointer;
470
+ font-weight: 500;
471
+ transition: all 0.2s ease;
472
+ }
473
+
474
+ .payment-option:hover {
475
+ background-color: #eaeaea;
476
+ }
477
+
478
+ .close-button {
479
+ width: 100%;
480
+ padding: 10px;
481
+ background-color: #f0f0f0;
482
+ border: 1px solid #ddd;
483
+ border-radius: 4px;
484
+ cursor: pointer;
485
+ }
486
+ `
487
+ };
488
+ ```
489
+
490
+ ## Vue Component Format
491
+
492
+ ### Basic Structure
493
+
494
+ ```js
495
+ module.exports = {
496
+ name: "MyComponent",
497
+
498
+ props: {
499
+ title: { required: true },
500
+ count: { default: 0 }
501
+ },
502
+ // Or in array format:
503
+ // props: [
504
+ // 'title',
505
+ // 'count'
506
+ // ],
507
+
508
+ template: /* html */`
509
+ <div class="my-component">
510
+ <h1>{{ title }}</h1>
511
+ <button @click="increment">Count: {{ counter }}</button>
512
+ </div>
513
+ `,
514
+
515
+ style: /* css */`
516
+ .my-component { padding: 20px; }
517
+ .my-component h1 { color: blue; }
518
+ `,
519
+
520
+ data: /* js */`
521
+ function() {
522
+ return {
523
+ counter: 0
524
+ };
525
+ }
526
+ `,
527
+
528
+ methods: /* js */`
529
+ {
530
+ increment() {
531
+ this.counter++;
532
+ }
533
+ }
534
+ `,
535
+
536
+ mounted: /* js */`
537
+ function() {
538
+ console.log('Component mounted!');
539
+ }
540
+ `
541
+ };
542
+ ```
543
+
544
+ ### Available Fields
545
+
546
+ | Field | Format | Description |
547
+ |-------|--------|-------------|
548
+ | `name` | String | Component name (required) |
549
+ | `props` | Object | Props definition (not a string) |
550
+ | `template` | Template literal | HTML template with Vue syntax |
551
+ | `style` | Template literal | CSS styles for the component |
552
+ | `data` | Template literal | Function returning initial state |
553
+ | `computed` | Template literal | Object with computed properties |
554
+ | `watch` | Template literal | Object with watchers |
555
+ | `methods` | Template literal | Object with methods |
556
+ | `mounted` | Template literal | Lifecycle hook function |
557
+ | `created`, `beforeMount`, `beforeUpdate`, `updated`, `beforeDestroy`, `destroyed` | Template literal | Other lifecycle hooks |
558
+
559
+ ### Key Rules
560
+
561
+ 1. **Use template literals** (backticks) for `template`, `style`, `data`, `methods`, etc.
562
+ 2. **Props is an object**, not a template literal
563
+ 3. **Comments are optional** but recommended: `/* html */`, `/* css */`, `/* js */`
564
+ 4. **Unused fields** can be omitted or set to `null`
565
+ 5. **Empty file marker**: `/* EMPTY FILE */` for placeholder files
566
+
567
+ ### Minimal Example
568
+
569
+ ```js
570
+ module.exports = {
571
+ name: "HelloWorld",
572
+ template: /* html */`
573
+ <div>Hello, World!</div>
574
+ `
575
+ };
576
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyber-elx",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "description": "CyberOcean CLI tool to upload/download ELX custom pages",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "chalk": "^4.1.2",
27
27
  "commander": "^11.1.0",
28
28
  "inquirer": "^8.2.6",
29
- "jsonc-parser": "^3.2.0"
29
+ "jsonc-parser": "^3.2.0",
30
+ "vue-template-compiler": "^2.7.14"
30
31
  }
31
32
  }
package/src/api.js CHANGED
@@ -28,6 +28,58 @@ function createApiClient(config) {
28
28
  async getDefaultPages() {
29
29
  const response = await client.get('/api/plugin_api/el-x/get_defaults_for_elx_pages');
30
30
  return response.data;
31
+ },
32
+
33
+ // SPA General Pages
34
+ async getGeneralPages() {
35
+ const response = await client.get('/api/plugin_api/el-x/general_pages_elx_spa');
36
+ if(response.data?.debug) {
37
+ console.log(response.data.debug);
38
+ }
39
+ console.log(response.data);
40
+ return response.data;
41
+ },
42
+
43
+ async setGeneralPages(items) {
44
+ const response = await client.post('/api/plugin_api/el-x/general_pages_elx_spa', { items });
45
+ if(response.data?.debug) {
46
+ console.log(response.data.debug);
47
+ }
48
+ return response.data;
49
+ },
50
+
51
+ // SPA Teacher Dashboard
52
+ async getTeacherDashboard() {
53
+ const response = await client.get('/api/plugin_api/el-x/teacher_dashboard_elx_spa');
54
+ if(response.data?.debug) {
55
+ console.log(response.data.debug);
56
+ }
57
+ return response.data;
58
+ },
59
+
60
+ async setTeacherDashboard(items) {
61
+ const response = await client.post('/api/plugin_api/el-x/teacher_dashboard_elx_spa', { items });
62
+ if(response.data?.debug) {
63
+ console.log(response.data.debug);
64
+ }
65
+ return response.data;
66
+ },
67
+
68
+ // SPA Student Dashboard
69
+ async getStudentDashboard() {
70
+ const response = await client.get('/api/plugin_api/el-x/student_dashboard_elx_spa');
71
+ if(response.data?.debug) {
72
+ console.log(response.data.debug);
73
+ }
74
+ return response.data;
75
+ },
76
+
77
+ async setStudentDashboard(items) {
78
+ const response = await client.post('/api/plugin_api/el-x/student_dashboard_elx_spa', { items });
79
+ if(response.data?.debug) {
80
+ console.log(response.data.debug);
81
+ }
82
+ return response.data;
31
83
  }
32
84
  };
33
85
  }
package/src/cache.js CHANGED
@@ -10,13 +10,15 @@ function getCachePath(cwd = process.cwd()) {
10
10
  function readCache(cwd = process.cwd()) {
11
11
  const cachePath = getCachePath(cwd);
12
12
  if (!fs.existsSync(cachePath)) {
13
- return { pages: {} };
13
+ return { pages: {}, spa: {} };
14
14
  }
15
15
  try {
16
16
  const content = fs.readFileSync(cachePath, 'utf-8');
17
- return JSON.parse(content);
17
+ const cache = JSON.parse(content);
18
+ if (!cache.spa) cache.spa = {};
19
+ return cache;
18
20
  } catch (e) {
19
- return { pages: {} };
21
+ return { pages: {}, spa: {} };
20
22
  }
21
23
  }
22
24
 
@@ -42,6 +44,18 @@ function setPageTimestamp(cache, type, key, updated_at) {
42
44
  cache.pages[cacheKey] = { updated_at };
43
45
  }
44
46
 
47
+ // SPA cache functions (per-folder caching)
48
+ function getSpaTimestamp(cache, spaKey) {
49
+ return cache.spa[spaKey]?.updated_at || null;
50
+ }
51
+
52
+ function setSpaTimestamp(cache, spaKey, updated_at) {
53
+ if (!cache.spa) {
54
+ cache.spa = {};
55
+ }
56
+ cache.spa[spaKey] = { updated_at };
57
+ }
58
+
45
59
  module.exports = {
46
60
  CACHE_FILE,
47
61
  getCachePath,
@@ -49,5 +63,7 @@ module.exports = {
49
63
  writeCache,
50
64
  getPageCacheKey,
51
65
  getPageTimestamp,
52
- setPageTimestamp
66
+ setPageTimestamp,
67
+ getSpaTimestamp,
68
+ setSpaTimestamp
53
69
  };